Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
5.2 KiB
Preview Deployments
apps/server/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
- Start: discover config → allocate port → generate compose + Caddyfile →
docker compose up --build -d→ health check → emitpreview:ready - Stop:
docker compose down --volumes --remove-orphans→ clean up.cw-previews/<id>/→ emitpreview:stopped - List:
docker compose ls --filter name=cw-preview→ parse container labels → reconstruct status - 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: apps/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
PreviewManagerinstantiated inapps/server/container.tswith(projectRepository, eventBus, workspaceRoot)- 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.yml- Docker must be installed and running on the host