Files
Codewalkers/docs/preview.md
Lukas May 143aad58e8 feat: Replace per-preview Caddy sidecars with shared gateway architecture
Refactor preview deployments to use a single shared Caddy gateway container
with subdomain routing (<previewId>.localhost:<port>) instead of one Caddy
sidecar and one port per preview. Adds dev/preview modes, git worktree
support for branch checkouts, and auto-start on phase:pending_review.

- Add GatewayManager for shared Caddy lifecycle + Caddyfile generation
- Add git worktree helpers for preview mode branch checkouts
- Add dev mode: volume-mount + dev server image instead of build
- Remove per-preview Caddy sidecar and port publishing
- Use shared cw-preview-net Docker network with container name DNS
- Auto-start previews when phase enters pending_review
- Delete unused PreviewPanel.tsx
- Update all tests (40 pass), docs, events, CLI, tRPC, frontend
2026-03-05 12:22:29 +01:00

9.4 KiB

Preview Deployments

apps/server/preview/ — Docker-based preview deployments for reviewing changes in a running application.

Overview

Preview deployments let reviewers spin up a branch in local Docker containers. A single shared Caddy gateway handles subdomain routing for all active previews, accessed at http://<previewId>.localhost:<gatewayPort>.

Two modes:

  • Preview mode: Checks out target branch into a git worktree, builds Docker images, serves production-like output.
  • Dev mode: Mounts the agent's worktree into a container with a dev server image (e.g. node:20-alpine), enabling hot reload.

Auto-start: When a phase enters pending_review, a preview is automatically started for the phase's branch (if the initiative has exactly one project).

Key design decision: No database table. Docker IS the source of truth. Instead of persisting rows, we query Docker directly via compose project names, container labels, and docker compose CLI commands.

Architecture

.cw-previews/
  gateway/
    docker-compose.yml       ← single Caddy container, one port
    Caddyfile                ← regenerated on each preview add/remove
  <previewId>/
    docker-compose.yml       ← per-preview stack (no published ports)
    source/                  ← git worktree (preview mode only)

Docker:
  network: cw-preview-net    ← external, shared by gateway + all previews
  cw-preview-gateway         ← Caddy on one port, subdomain routing
  cw-preview-<id>            ← per-preview compose project (services only)

Routing:
  <previewId>.localhost:<gatewayPort> → cw-preview-<id>-<service>:<port>
PreviewManager
  ├── GatewayManager (shared Caddy gateway lifecycle + Caddyfile generation)
  ├── ConfigReader (discover .cw-preview.yml / compose / Dockerfile)
  ├── ComposeGenerator (generate per-preview docker-compose.yml)
  ├── DockerClient (thin wrapper around docker compose CLI + network ops)
  ├── HealthChecker (poll service healthcheck endpoints via subdomain URL)
  ├── PortAllocator (find next available port 9100-9200 for gateway)
  └── Worktree helper (git worktree add/remove for preview mode)

Lifecycle

  1. Start: ensure gateway → discover config → create worktree (preview) or use provided path (dev) → generate compose → docker compose up --build -d → update gateway routes → health check → emit preview:ready
  2. Stop: docker compose down --volumes --remove-orphans → remove worktree → clean up .cw-previews/<id>/ → update gateway routes → stop gateway if no more previews → emit preview:stopped
  3. List: docker compose ls --filter name=cw-preview → skip gateway project → parse container labels → reconstruct status
  4. Shutdown: stopAll() called on server shutdown — stops all previews, then stops gateway

Gateway

The GatewayManager class manages a single shared Caddy container:

  • ensureGateway() — idempotent. Creates the cw-preview-net Docker network, checks if gateway is already running, allocates a port (9100-9200) if needed, writes compose + Caddyfile, starts Caddy with --watch flag.
  • updateRoutes() — regenerates the full Caddyfile from all active previews. Caddy's --watch flag auto-reloads on file change (no docker exec needed).
  • stopGateway() — composes down the gateway, removes the Docker network, cleans up the gateway directory.

Gateway Caddyfile format:

{
  auto_https off
}

abc123.localhost:9100 {
  handle_path /api/* {
    reverse_proxy cw-preview-abc123-backend:8080
  }
  handle {
    reverse_proxy cw-preview-abc123-frontend:3000
  }
}

xyz789.localhost:9100 {
  handle {
    reverse_proxy cw-preview-xyz789-app:3000
  }
}

Routes are sorted by specificity (longer paths first) to ensure correct matching.

Subdomain Routing

Previews are accessed at http://<previewId>.localhost:<gatewayPort>.

  • Chrome / Firefox: Resolve *.localhost to 127.0.0.1 natively. No DNS config needed.
  • Safari: Requires a /etc/hosts entry: 127.0.0.1 <previewId>.localhost for each preview.

Docker Labels

All preview containers get cw.* labels for metadata retrieval:

Label Purpose
cw.preview "true" — marker for filtering
cw.initiative-id Initiative ID
cw.phase-id Phase ID (optional)
cw.project-id Project ID
cw.branch Branch name
cw.port Gateway port
cw.preview-id Nanoid for this deployment
cw.mode "preview" or "dev"

Compose Project Naming

  • Gateway: cw-preview-gateway (single instance)
  • Previews: cw-preview-<nanoid> — filtered via docker compose ls --filter name=cw-preview, gateway excluded from listings
  • Container names: cw-preview-<id>-<service> — unique DNS names on the shared network

Networking

  • cw-preview-net — external Docker bridge network shared by gateway + all preview stacks
  • internal — per-preview bridge network for inter-service communication
  • Public services join both networks; internal services (e.g. databases) only join internal

Configuration

Preview configuration is discovered from the project directory in this order:

1. .cw-preview.yml (explicit CW config)

version: 1
services:
  frontend:
    build:
      context: .
      dockerfile: apps/web/Dockerfile
    port: 3000
    route: /
    healthcheck:
      path: /
      interval: 5s
      retries: 10
    env:
      VITE_API_URL: /api
    dev:
      image: node:20-alpine
      command: npm run dev -- --host 0.0.0.0
      workdir: /app

  backend:
    build:
      context: .
      dockerfile: packages/api/Dockerfile
    port: 8080
    route: /api
    healthcheck:
      path: /health
    env:
      DATABASE_URL: postgres://db:5432/app

  db:
    image: postgres:16-alpine
    port: 5432
    internal: true    # not exposed through proxy
    env:
      POSTGRES_PASSWORD: preview

The dev section is optional per service. When present and mode is dev:

  • image (required) — Docker image to run
  • command — override entrypoint
  • workdir — container working directory (default /app)

In dev mode, the project directory is volume-mounted into the container and node_modules gets an anonymous volume to prevent host overwrite.

2. docker-compose.yml / compose.yml (existing compose passthrough)

If found, the existing compose file is used with gateway network injection.

3. Dockerfile (single-service fallback)

If only a Dockerfile exists, creates a single app service building from . with port 3000.

Module Files

File Purpose
types.ts PreviewConfig, PreviewStatus, labels, constants, dev config types
config-reader.ts Discovery + YAML parsing (including dev section)
compose-generator.ts Per-preview Docker Compose YAML + label generation
gateway.ts GatewayManager class + Caddyfile generation
worktree.ts Git worktree create/remove helpers
docker-client.ts Docker CLI wrapper (execa) + network operations
health-checker.ts Service readiness polling via subdomain URL
port-allocator.ts Port 9100-9200 allocation with TCP bind test
manager.ts PreviewManager class (start/stop/list/status/stopAll + auto-start)
index.ts Barrel exports

Events

Event Payload
preview:building {previewId, initiativeId, branch, gatewayPort, mode, phaseId?}
preview:ready {previewId, initiativeId, branch, gatewayPort, url, mode, phaseId?}
preview:stopped {previewId, initiativeId}
preview:failed {previewId, initiativeId, error}

Auto-Start

PreviewManager.setupEventListeners() listens for phase:pending_review events:

  1. Loads the initiative and its projects
  2. If exactly one project: auto-starts a preview in preview mode
  3. Branch is derived from phaseBranchName(initiative.branch, phase.name)
  4. Errors are caught and logged (best-effort, never blocks the phase transition)

tRPC Procedures

Procedure Type Input
startPreview mutation {initiativeId, phaseId?, projectId, branch, mode?, worktreePath?}
stopPreview mutation {previewId}
listPreviews query {initiativeId?}
getPreviewStatus query {previewId}

mode defaults to 'preview'. Set to 'dev' with a worktreePath for dev mode.

CLI Commands

cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>] [--mode preview|dev]
cw preview stop <previewId>
cw preview list [--initiative <id>]
cw preview status <previewId>

Frontend

The Review tab shows preview status inline:

  • No preview: "Start Preview" button
  • Building: Spinner + "Building preview..."
  • Running: Green dot + http://<id>.localhost:<port> link + Stop button
  • Failed: Error message + Retry button

Polls getPreviewStatus with refetchInterval: 3000 while active.

Container Wiring

  • PreviewManager instantiated in apps/server/container.ts with (projectRepository, eventBus, workspaceRoot, phaseRepository, initiativeRepository)
  • Added to Container interface and toContextDeps()
  • GracefulShutdown calls previewManager.stopAll() during shutdown
  • requirePreviewManager(ctx) helper in apps/server/trpc/routers/_helpers.ts

Dependencies

  • js-yaml + @types/js-yaml — for parsing .cw-preview.yml
  • simple-git — for git worktree operations
  • Docker must be installed and running on the host