Files
Codewalkers/docs/preview.md
Lukas May 270a5cb21d feat: Add Docker-based preview deployments for phase review
Preview deployments let reviewers spin up the app at a specific branch
in local Docker containers, accessible through a single Caddy reverse
proxy port. Docker is the source of truth — no database table needed.

New module: src/preview/ with config discovery (.cw-preview.yml →
compose → Dockerfile fallback), compose generation, Docker CLI wrapper,
health checking, and port allocation (9100-9200 range).
2026-02-10 13:24:56 +01:00

5.2 KiB

Preview Deployments

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

Overview

When a phase enters pending_review, reviewers can spin up the app at a specific branch in local Docker containers, accessible through a single port via a Caddy reverse proxy.

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

PreviewManager
  ├── ConfigReader (discover .cw-preview.yml / compose / Dockerfile)
  ├── ComposeGenerator (generate docker-compose.yml + Caddyfile)
  ├── DockerClient (thin wrapper around docker compose CLI)
  ├── HealthChecker (poll service healthcheck endpoints)
  └── PortAllocator (find next available port 9100-9200)

Lifecycle

  1. Start: discover config → allocate port → generate compose + Caddyfile → docker compose up --build -d → health check → emit preview:ready
  2. Stop: docker compose down --volumes --remove-orphans → clean up .cw-previews/<id>/ → emit preview:stopped
  3. List: docker compose ls --filter name=cw-preview → parse container labels → reconstruct status
  4. Shutdown: stopAll() called on server shutdown to prevent orphaned containers

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 Host port
cw.preview-id Nanoid for this deployment

Compose Project Naming

Project names follow cw-preview-<nanoid> convention. This enables filtering via docker compose ls --filter name=cw-preview.

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: packages/web/Dockerfile
    port: 3000
    route: /
    healthcheck:
      path: /
      interval: 5s
      retries: 10
    env:
      VITE_API_URL: /api

  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

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

If found, the existing compose file is wrapped with a Caddy sidecar.

3. Dockerfile (single-service fallback)

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

Reverse Proxy: Caddy

Caddy runs as a container in the same Docker network. Only Caddy publishes a port to the host. Generated Caddyfile:

:80 {
  handle_path /api/* {
    reverse_proxy backend:8080
  }
  handle {
    reverse_proxy frontend:3000
  }
}

Module Files

File Purpose
types.ts PreviewConfig, PreviewStatus, labels, constants
config-reader.ts Discovery + YAML parsing
compose-generator.ts Docker Compose YAML + Caddyfile generation
docker-client.ts Docker CLI wrapper (execa)
health-checker.ts Service readiness polling
port-allocator.ts Port 9100-9200 allocation with bind test
manager.ts PreviewManager class (start/stop/list/status/stopAll)
index.ts Barrel exports

Events

Event Payload
preview:building {previewId, initiativeId, branch, port}
preview:ready {previewId, initiativeId, branch, port, url}
preview:stopped {previewId, initiativeId}
preview:failed {previewId, initiativeId, error}

tRPC Procedures

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

CLI Commands

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

Frontend

PreviewPanel component in the Review tab:

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

Polls getPreviewStatus with refetchInterval: 3000 while active.

Container Wiring

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

Dependencies

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