---
phase: 07-deploy-v1
plan: 03
type: execute
wave: 3
depends_on:
  - 07-02
files_modified:
  - backend/docker-compose.prod.yaml
  - backend/deploy/Caddyfile
  - backend/README.md
autonomous: true
requirements:
  - DEPLOY-02
  - DEPLOY-03
  - DEPLOY-04
  - DEPLOY-05

must_haves:
  truths:
    - "D-01/D-02/D-03: docker-compose.prod.yaml defines postgres, web, worker, and caddy services for Hetzner VM"
    - "D-08: The web service has command: /app/web and depends_on postgres with service_healthy condition"
    - "The worker service has command: /app/worker with the same depends_on condition"
    - "D-04: Caddy service mounts deploy/Caddyfile and has persistent caddy_data and caddy_config named volumes"
    - "Postgres service has no ports: directive (internal-only, per RESEARCH Pitfall 5)"
    - "D-11: backend/README.md documents first-time deploy, routine deploy, rollback, and incident triage sections"
    - "backend/deploy/Caddyfile uses {$DOMAIN} env var interpolation for the site block"
  artifacts:
    - path: "backend/docker-compose.prod.yaml"
      provides: "Production compose stack: postgres + web + worker + caddy"
      contains: "caddy_data"
    - path: "backend/deploy/Caddyfile"
      provides: "Caddy reverse proxy config with TLS and {$DOMAIN} interpolation"
      contains: "{$DOMAIN}"
    - path: "backend/README.md"
      provides: "Extended runbook with Deploy, Rollback, Incident sections"
      contains: "## Deploy"
  key_links:
    - from: "docker-compose.prod.yaml web service"
      to: "Dockerfile /app/web binary"
      via: "command: /app/web with image: ${IMAGE}:${TAG}"
      pattern: "/app/web"
    - from: "caddy service"
      to: "deploy/Caddyfile"
      via: "volume bind-mount ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro"
      pattern: "Caddyfile"
    - from: "Caddyfile"
      to: "web:8080"
      via: "reverse_proxy web:8080"
      pattern: "reverse_proxy"
---

<objective>
Deliver the production compose stack, Caddy config, and runbook documentation (D-01..D-05, D-08, DEPLOY-02..DEPLOY-05):

1. Create `backend/docker-compose.prod.yaml` — production compose file with postgres, web, worker, and caddy services. The web and worker services run the same image with different `command:` values per D-08. Postgres has no host port binding (internal network only). Caddy handles TLS via Let's Encrypt with a persistent volume.
2. Create `backend/deploy/Caddyfile` — single-site reverse proxy config using `{$DOMAIN}` env interpolation. Caddy automatically provisions TLS and HTTP→HTTPS redirect.
3. Extend `backend/README.md` — add Deploy, Rollback, and Incident sections documenting SSH + docker compose commands for first-time deploy, routine deploys, and rollback by image tag.

Purpose: Without docker-compose.prod.yaml and the Caddyfile, the product cannot run in production. Without the runbook, operators have no documented procedure for deploy and incident response (DEPLOY-05).

Output: Three files committed. `docker compose -f backend/docker-compose.prod.yaml config` validates syntax without errors.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-PATTERNS.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-02-SUMMARY.md
</context>

<interfaces>
<!-- Key patterns from PATTERNS.md and RESEARCH.md for the compose file and Caddyfile. -->

From backend/compose.yaml (postgres service pattern to mirror in prod — with key differences):
```yaml
# DEV (compose.yaml) — has ports + minio:
postgres:
  image: postgres:16-alpine
  restart: unless-stopped
  environment:
    POSTGRES_DB: xtablo
    POSTGRES_USER: xtablo
    POSTGRES_PASSWORD: xtablo
  ports:
    - "5432:5432"          # REMOVE in prod (no host port)
  volumes:
    - postgres_data:/var/lib/postgresql/data
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"]
    interval: 5s
    timeout: 5s
    retries: 10
```

PROD differences (from PATTERNS.md):
- No `ports:` on postgres (Pitfall 5 — internal-only)
- No minio / minio-init services (replaced by R2 env vars)
- POSTGRES_PASSWORD reads from .env.prod: ${POSTGRES_PASSWORD}
- healthcheck: interval: 10s, retries: 10

Web service pattern (new):
```yaml
web:
  image: ${IMAGE:-ghcr.io/yourusername/xtablo}:${TAG:-latest}
  command: /app/web
  restart: unless-stopped
  env_file: .env.prod
  depends_on:
    postgres:
      condition: service_healthy
  expose:
    - "8080"
```

Caddy service pattern (RESEARCH Pattern 5):
```yaml
caddy:
  image: caddy:2-alpine
  restart: unless-stopped
  ports:
    - "80:80"
    - "443:443"
    - "443:443/udp"
  volumes:
    - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
    - caddy_data:/data
    - caddy_config:/config
```

Caddyfile pattern (RESEARCH Pattern 6):
```
{$DOMAIN} {
    reverse_proxy web:8080
}
```

README structure (backend/README.md):
- Existing README has H2 sections. New sections appended after existing content.
- Section names: ## Deploy, ## Rollback, ## Incident Runbook
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: docker-compose.prod.yaml and deploy/Caddyfile</name>
  <files>
    backend/docker-compose.prod.yaml
    backend/deploy/Caddyfile
  </files>
  <read_first>
    - backend/compose.yaml (full file — exact postgres healthcheck pattern, volume names, to mirror correctly)
    - backend/.env.example (after Plan 02 update — all var names that .env.prod will contain, especially POSTGRES_PASSWORD, POSTGRES_USER, POSTGRES_DB)
    - backend/deploy/ (check if directory exists; create it if not)
  </read_first>
  <action>
    Create `backend/docker-compose.prod.yaml`. Use compose v2 syntax (no `version:` key at top — matches existing compose.yaml which also omits it). Define these services:

    `postgres`: image postgres:16-alpine. restart: unless-stopped. environment: POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD all read from env vars with defaults `${POSTGRES_DB:-xtablo}`, `${POSTGRES_USER:-xtablo}`, `${POSTGRES_PASSWORD}` (no default for password — operator must set it). volumes: postgres_data:/var/lib/postgresql/data. healthcheck: test `["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-xtablo}"]`, interval: 10s, timeout: 5s, retries: 10. NO `ports:` directive (RESEARCH Pitfall 5 / PATTERNS.md "Postgres ports in prod compose"). No container_name (let compose generate it).

    `web`: image: `${IMAGE:-ghcr.io/yourusername/xtablo}:${TAG:-latest}`. command: /app/web. restart: unless-stopped. env_file: .env.prod. depends_on: `postgres: condition: service_healthy`. expose: ["8080"]. No ports: (Caddy handles external traffic).

    `worker`: image: `${IMAGE:-ghcr.io/yourusername/xtablo}:${TAG:-latest}`. command: /app/worker. restart: unless-stopped. env_file: .env.prod. depends_on: `postgres: condition: service_healthy`. No expose or ports.

    `caddy`: image: caddy:2-alpine. restart: unless-stopped. ports: ["80:80", "443:443", "443:443/udp"]. volumes: `./deploy/Caddyfile:/etc/caddy/Caddyfile:ro`, `caddy_data:/data`, `caddy_config:/config`. depends_on: [web] (caddy should start after web is up).

    volumes block: `postgres_data:`, `caddy_data:`, `caddy_config:` (all named, no driver options needed).

    Add inline comments at the top of the file: reference D-01 (Hetzner VM), D-02 (plain compose), D-03 (Postgres on VM), D-04 (Caddy TLS), D-08 (same image twice with different commands).

    Create `backend/deploy/Caddyfile`. Content is the single-site reverse proxy block using `{$DOMAIN}` for Caddy env var interpolation: the site block is `{$DOMAIN} { reverse_proxy web:8080 }`. Caddy automatically handles HTTP→HTTPS redirect and Let's Encrypt certificate issuance when a domain name is provided (no explicit tls or redir directives needed). Add a comment at top explaining to set DOMAIN in .env.prod and reference the Let's Encrypt staging note from RESEARCH Pitfall 4 for initial testing.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && docker compose -f docker-compose.prod.yaml config --quiet && echo "COMPOSE CONFIG OK"</automated>
  </verify>
  <done>
    `docker compose -f backend/docker-compose.prod.yaml config` exits 0 (valid compose syntax). File contains postgres, web, worker, and caddy services. postgres service has no `ports:` directive. caddy_data and caddy_config are named volumes. web and worker both have `depends_on: postgres: condition: service_healthy`. backend/deploy/Caddyfile exists with `{$DOMAIN}` site block and `reverse_proxy web:8080`.
  </done>
</task>

<task type="auto">
  <name>Task 2: Extend backend/README.md with Deploy, Rollback, and Incident Runbook sections</name>
  <files>
    backend/README.md
  </files>
  <read_first>
    - backend/README.md (full file — read existing structure, last line number, H2 section style, to append correctly without duplicating)
    - backend/docker-compose.prod.yaml (created in Task 1 — reference exact file name and service names in runbook commands)
    - backend/.env.example (after Plan 02 — reference exact env var names operators must set in .env.prod)
  </read_first>
  <action>
    Append three new H2 sections to `backend/README.md` after the existing content. Do not modify existing sections. Use the same header style (H2 = `##`, H3 = `###`) as the existing README.

    `## Deploy` section: Explain the production host is a Hetzner VM running plain Docker Compose (D-01, D-02). Subsections:

    `### Prerequisites` — List what must be installed on the VM: Docker + Docker Compose plugin, git (optional but useful). Note that Postgres runs inside the compose stack (D-03), no external DB needed.

    `### First-time setup` — Numbered steps:
    1. SSH to the VM.
    2. Clone or copy the repo (or just copy the `backend/` directory).
    3. Create `.env.prod` in the `backend/` directory by copying `.env.example` and filling in real values. List the mandatory vars: `DATABASE_URL` (internal compose URL: `postgres://xtablo:<POSTGRES_PASSWORD>@postgres:5432/xtablo?sslmode=disable`), `SESSION_SECRET` (generate with `openssl rand -hex 32`), `S3_ENDPOINT` (R2 endpoint URL), `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_USE_PATH_STYLE=false` (R2), `ENV=production`, `PORT=8080`, `DOMAIN=app.yourdomain.com`. Also set `POSTGRES_PASSWORD` in the .env.prod (used by the postgres service). Security note: `chmod 600 .env.prod`.
    4. Build the image: `docker build -f Dockerfile -t ghcr.io/yourusername/xtablo:v0.1.0 .` (from inside `backend/`) and export IMAGE + TAG via env: `export IMAGE=ghcr.io/yourusername/xtablo TAG=v0.1.0`.
    5. Start the stack: `docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d`.
    6. Verify: `curl https://your-domain.com/healthz` should return `{"status":"ok"}`. `curl https://your-domain.com/readyz` should return `{"status":"ok","db":"ok"}`.
    7. Note about TLS staging: for initial testing, add a global Caddy block in deploy/Caddyfile to use Let's Encrypt staging endpoint to avoid rate limits (RESEARCH Pitfall 4).

    `### Deploying a new version` — Steps:
    1. Build and tag new image: `docker build -f Dockerfile -t ghcr.io/yourusername/xtablo:v0.2.0 .`.
    2. On the VM: update `TAG=v0.2.0` in .env.prod (or pass via env).
    3. `docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d` — compose recreates only changed services. Migrations run automatically at web startup (D-10).

    `## Rollback` section: Explain that rollback = redeploying the previous image tag (D-11). Normal rollback commands: (1) Update TAG in .env.prod to the previous tag (e.g., `v0.1.0`). (2) `docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d`. Note that `goose.Up()` is idempotent — rolling back to a previous image does not automatically run `goose down`. Document `goose down` as a break-glass step: if the new migration introduced a schema change that is incompatible with the old image, connect to Postgres inside the container and run `docker exec -it <postgres-container> psql -U xtablo -d xtablo` then manually call goose CLI (not in the image — must install separately or use the goose Docker image) to run `goose down`.

    `## Incident Runbook` section with subsections:

    `### /readyz returns 503` — Check Postgres container: `docker compose -f docker-compose.prod.yaml ps`. If postgres is down, `docker compose -f docker-compose.prod.yaml up -d postgres`. Check web logs: `docker compose -f docker-compose.prod.yaml logs web --tail=50`.

    `### Caddy TLS certificate errors` — Check caddy logs: `docker compose -f docker-compose.prod.yaml logs caddy --tail=50`. If "too many certificates" error: Caddy hit Let's Encrypt rate limit (RESEARCH Pitfall 4). Confirm `caddy_data` volume exists and is mounted. If volume was lost, must wait up to 1 week or use Let's Encrypt staging. Restore from a caddy_data volume backup if available.

    `### Checking logs` — `docker compose -f docker-compose.prod.yaml logs web --tail=100 --follow`. All application logs are JSON in production (ENV=production activates slog JSON handler).

    `### Debugging the distroless container` — The runtime image has no shell (RESEARCH Pitfall 7). Use an ephemeral busybox container on the same network: `docker run --rm -it --network container:<web-container-id> busybox sh`.
  </action>
  <verify>
    <automated>grep -c "## Deploy\|## Rollback\|## Incident Runbook" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/README.md</automated>
  </verify>
  <done>
    `grep -c "## Deploy\|## Rollback\|## Incident Runbook" backend/README.md` returns 3 (one match per section). README contains first-time setup steps with DATABASE_URL (internal postgres URL), SESSION_SECRET generation command, S3/R2 var list, chmod 600 .env.prod reminder. Rollback section documents TAG update + compose up. Incident section covers /readyz 503, Caddy TLS errors, log viewing, and distroless debug container.
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Internet → Caddy :80/:443 | TLS-terminated; all external traffic enters here |
| Caddy → web:8080 | Internal Docker network; plaintext HTTP; trusted |
| Host .env.prod → compose services | Secrets injected via env_file; file must be 600 on host |
| Postgres → host | No ports binding; not accessible from internet |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-09 | Information Disclosure | Postgres port exposure | mitigate | No `ports:` on postgres service in docker-compose.prod.yaml; postgres only reachable within compose network (PATTERNS.md critical warning 4) |
| T-07-10 | Information Disclosure | .env.prod on host | mitigate | README runbook includes `chmod 600 .env.prod` instruction; .env.prod is gitignored (never committed) |
| T-07-11 | Denial of Service | caddy_data volume loss | accept | README incident section documents recovery steps; TLS rate-limit risk acknowledged in runbook with staging env guidance |
| T-07-12 | Denial of Service | web service races postgres startup | mitigate | `depends_on: postgres: condition: service_healthy` with postgres healthcheck (pg_isready); prevents goose.Up() racing Postgres init (RESEARCH Pitfall 5) |
| T-07-13 | Elevation of Privilege | caddy container exposes ports 80/443 | accept | Caddy is the intended TLS terminator; ports exposure is by design; attack surface is Caddy's own (well-audited) HTTP/TLS stack |
</threat_model>

<verification>
Compose syntax validation:

```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && docker compose -f docker-compose.prod.yaml config --quiet
```

Caddyfile exists:

```
test -f /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/deploy/Caddyfile && echo "EXISTS" && cat /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/deploy/Caddyfile
```

README runbook section count:

```
grep -c "## Deploy\|## Rollback\|## Incident Runbook" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/README.md
```
</verification>

<success_criteria>
1. `docker compose -f backend/docker-compose.prod.yaml config --quiet` exits 0 (valid syntax)
2. `backend/docker-compose.prod.yaml` contains postgres, web, worker, caddy services
3. postgres service has no `ports:` directive
4. web service has `command: /app/web` and `depends_on: postgres: condition: service_healthy`
5. worker service has `command: /app/worker` and same depends_on
6. caddy service has `caddy_data` and `caddy_config` named volumes
7. `backend/deploy/Caddyfile` exists and contains `{$DOMAIN}` and `reverse_proxy web:8080`
8. `grep -c "## Deploy\|## Rollback\|## Incident Runbook" backend/README.md` returns 3
9. README deploy section includes `chmod 600 .env.prod` and `openssl rand -hex 32` for SESSION_SECRET
10. README rollback section documents image tag redeployment (D-11)
</success_criteria>

<output>
After completion, create `.planning/phases/07-deploy-v1/07-03-SUMMARY.md`
</output>
