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
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
- 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 → emitpreview:ready - Stop:
docker compose down --volumes --remove-orphans→ remove worktree → clean up.cw-previews/<id>/→ update gateway routes → stop gateway if no more previews → emitpreview:stopped - List:
docker compose ls --filter name=cw-preview→ skip gateway project → parse container labels → reconstruct status - 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 thecw-preview-netDocker network, checks if gateway is already running, allocates a port (9100-9200) if needed, writes compose + Caddyfile, starts Caddy with--watchflag.updateRoutes()— regenerates the full Caddyfile from all active previews. Caddy's--watchflag auto-reloads on file change (nodocker execneeded).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
*.localhostto127.0.0.1natively. No DNS config needed. - Safari: Requires a
/etc/hostsentry:127.0.0.1 <previewId>.localhostfor 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 viadocker 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 stacksinternal— 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 runcommand— override entrypointworkdir— 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:
- Loads the initiative and its projects
- If exactly one project: auto-starts a preview in
previewmode - Branch is derived from
phaseBranchName(initiative.branch, phase.name) - 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
PreviewManagerinstantiated inapps/server/container.tswith(projectRepository, eventBus, workspaceRoot, phaseRepository, initiativeRepository)- Added to
Containerinterface andtoContextDeps() GracefulShutdowncallspreviewManager.stopAll()during shutdownrequirePreviewManager(ctx)helper inapps/server/trpc/routers/_helpers.ts
Dependencies
js-yaml+@types/js-yaml— for parsing.cw-preview.ymlsimple-git— for git worktree operations- Docker must be installed and running on the host