---
phase: 07-deploy-v1
verified: 2026-05-15T00:00:00Z
status: human_needed
score: 5/5 must-haves verified
overrides_applied: 0
human_verification:
  - test: "Build the Docker image: cd backend && docker build -f Dockerfile -t xtablo-test ."
    expected: "All three stages complete successfully — assets stage downloads Tailwind/HTMX/Sortable and compiles CSS; builder stage runs templ generate and compiles both /app/web and /app/worker with CGO_ENABLED=0; runtime stage copies binaries into distroless image."
    why_human: "Docker Engine is not installed on this development machine. The Dockerfile structure was verified by static inspection but the actual multi-stage build cannot be executed here."
  - test: "Validate compose syntax: cd backend && docker compose -f docker-compose.prod.yaml config --quiet"
    expected: "Exit 0 with no errors — all services, volumes, and environment variable references are syntactically valid."
    why_human: "Docker Engine is not installed on this development machine. YAML structure was verified by static inspection."
  - test: "End-to-end deploy smoke test on a VM: follow the README First-time setup steps with a test domain and verify /healthz and /readyz return expected JSON."
    expected: "curl https://<domain>/healthz returns {\"status\":\"ok\"}; curl https://<domain>/readyz returns {\"status\":\"ok\",\"db\":\"ok\"} after stack starts."
    why_human: "Actual VM deployment and Caddy TLS provisioning cannot be tested programmatically without network infrastructure."
---

# Phase 7: Deploy v1 Verification Report

**Phase Goal:** Deliver a single-container deployment: Dockerfile, compose stack, Caddyfile, and operational runbook so the Go+HTMX server can be deployed to a VPS with one command.
**Verified:** 2026-05-15
**Status:** human_needed
**Re-verification:** No — initial verification

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | D-07: A single Dockerfile builds both /app/web and /app/worker binaries in one image | ✓ VERIFIED | `backend/Dockerfile` exists with 3 named stages (assets, builder, runtime). Builder stage has two `CGO_ENABLED=0 GOOS=linux go build` commands targeting `./cmd/web` (-o /app/web) and `./cmd/worker` (-o /app/worker). Runtime stage copies both. |
| 2 | D-12: GET /healthz returns 200 with no DB connection (pure liveness) | ✓ VERIFIED | `HealthzHandler()` in handlers.go takes no args, returns 200+`{"status":"ok"}` immediately. Registered at `r.Get("/healthz", HealthzHandler())` in router.go. `TestHealthz_OK` passes (0.00s). |
| 3 | D-13: GET /readyz returns 200 when DB is reachable and 503 when DB is down | ✓ VERIFIED | `ReadyzHandler(pinger Pinger)` uses 2s timeout, pings DB. Returns 503+`{"status":"degraded","db":"down"}` on error, 200+`{"status":"ok","db":"ok"}` on success. Registered at `r.Get("/readyz", ReadyzHandler(pinger))`. `TestReadyz_OK` and `TestReadyz_Down` both pass. |
| 4 | D-09: Static assets served from embedded FS, not disk | ✓ VERIFIED | `backend/embed.go` declares `//go:embed all:static` → `var Static embed.FS`. `main.go` passes `assets.Static` to `web.NewRouter`. `router.go` uses `fs.Sub(staticFS, "static")` + `http.FileServer(http.FS(sub))`. |
| 5 | D-10: goose.Up() called at startup before router construction using embedded migrations | ✓ VERIFIED | `backend/internal/db/migrate.go` exports `RunMigrations` using goose.SetBaseFS + goose.Up. `main.go` line 74: `db.RunMigrations(ctx, pool, assets.Migrations)` called after pool creation, before `q := sqlc.New(pool)` and before `web.NewRouter(...)`. |
| 6 | D-01/D-02/D-03: docker-compose.prod.yaml defines postgres, web, worker, caddy services | ✓ VERIFIED | File exists with all 4 services. postgres (internal DB, D-03), web (command: /app/web), worker (command: /app/worker), caddy (TLS terminator, ports 80/443/443udp). |
| 7 | Postgres service has no ports directive (internal-only) | ✓ VERIFIED | No `ports:` key on postgres service. Only `expose: ["8080"]` on web service (internal). Caddy is the only service with host port bindings. |
| 8 | D-04: Caddy service mounts deploy/Caddyfile with persistent caddy_data and caddy_config volumes | ✓ VERIFIED | `./deploy/Caddyfile:/etc/caddy/Caddyfile:ro` volume mount. Named volumes `caddy_data:/data` and `caddy_config:/config` declared in volumes block. |
| 9 | backend/deploy/Caddyfile uses {$DOMAIN} env var interpolation | ✓ VERIFIED | Caddyfile contains `{$DOMAIN} { reverse_proxy web:8080 }`. |
| 10 | D-11: backend/README.md documents deploy, rollback, and incident triage sections | ✓ VERIFIED | `## Deploy` (line 241), `## Rollback` (line 403), `## Incident Runbook` (line 463) all present. First-time setup includes `chmod 600 .env.prod` and `openssl rand -hex 32` for SESSION_SECRET. Rollback section documents TAG update + `docker compose up -d`. |
| 11 | Full Go test suite passes: go test ./... | ✓ VERIFIED | All packages pass: `backend/internal/auth`, `backend/internal/db`, `backend/internal/files`, `backend/internal/jobs`, `backend/internal/web`, `backend/internal/web/ui`, `backend/templates`. Exit 0. |
| 12 | go build ./cmd/web/... succeeds (binary compiles with embedded assets) | ✓ VERIFIED | `go build ./cmd/web/...` exits 0 with no output (no compilation errors). |

**Score:** 5/5 ROADMAP success criteria verified (all 12 derived truths verified)

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `backend/embed.go` | //go:embed declarations for static/ and migrations/ | ✓ VERIFIED | Package `assets`. `//go:embed all:static` → `var Static embed.FS`. `//go:embed migrations` → `var Migrations embed.FS`. |
| `backend/internal/db/migrate.go` | RunMigrations function using pgx/v5/stdlib bridge and goose.Up() | ✓ VERIFIED | Exports `RunMigrations(ctx, pool, migrationsFS)`. Uses `goose.SetBaseFS`, `goose.SetDialect("postgres")`, `goose.Up(sqlDB, "migrations")`. |
| `backend/internal/web/handlers.go` | split HealthzHandler (no pinger) and new ReadyzHandler (pinger) | ✓ VERIFIED | `HealthzHandler() http.HandlerFunc` (no args). `ReadyzHandler(pinger Pinger) http.HandlerFunc` with 2s timeout. |
| `backend/internal/web/router.go` | NewRouter with fs.FS param, /readyz route, /healthz liveness route | ✓ VERIFIED | Signature uses `staticFS fs.FS`. Routes: `r.Get("/healthz", HealthzHandler())` and `r.Get("/readyz", ReadyzHandler(pinger))`. |
| `backend/cmd/web/main.go` | Calls RunMigrations and passes assets.Static to NewRouter | ✓ VERIFIED | Line 74: `db.RunMigrations(ctx, pool, assets.Migrations)`. Line 125: `web.NewRouter(pool, assets.Static, ...)`. |
| `backend/Dockerfile` | 3-stage multi-stage Docker build: assets, builder, runtime | ✓ VERIFIED | Stage `assets` (node:20-alpine): downloads Tailwind v4.0.0, HTMX@2, Sortable.js@1.15.7, compiles CSS. Stage `builder` (golang:1.26-alpine): runs `templ@v0.3.1020 generate`, builds both binaries with `CGO_ENABLED=0 GOOS=linux`. Stage runtime (`gcr.io/distroless/static-debian12:nonroot`): copies /app/web and /app/worker. No CMD. EXPOSE 8080. |
| `backend/.env.example` | Documented env vars including S3/R2, DOMAIN, MAX_UPLOAD_SIZE_MB | ✓ VERIFIED | All 8 new vars present (S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB, DOMAIN). All original vars preserved. |
| `backend/docker-compose.prod.yaml` | Production compose stack: postgres + web + worker + caddy | ✓ VERIFIED | All 4 services defined. postgres has no ports, web/worker use `env_file: .env.prod` + `depends_on: postgres: condition: service_healthy`. caddy has persistent named volumes. |
| `backend/deploy/Caddyfile` | Caddy reverse proxy config with TLS and {$DOMAIN} interpolation | ✓ VERIFIED | `{$DOMAIN} { reverse_proxy web:8080 }`. LE staging note included. |
| `backend/README.md` | Extended runbook with Deploy, Rollback, Incident sections | ✓ VERIFIED | Three H2 sections: `## Deploy` (line 241), `## Rollback` (line 403), `## Incident Runbook` (line 463). All required subsections present. |

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `backend/cmd/web/main.go` | `backend/internal/db/migrate.go` | `db.RunMigrations(ctx, pool, assets.Migrations)` | ✓ WIRED | Line 74 in main.go, confirmed by grep. |
| `backend/cmd/web/main.go` | `backend/embed.go` | `assets.Static` passed to `web.NewRouter` | ✓ WIRED | `import assets "backend"` (line 22). `assets.Static` used at line 125. |
| `backend/internal/web/router.go` | `ReadyzHandler` | `r.Get("/readyz", ReadyzHandler(pinger))` | ✓ WIRED | Line 121 in router.go. |
| `Dockerfile builder stage` | `templ generate` | `go install templ@v0.3.1020 && templ generate` | ✓ WIRED | Line 72 in Dockerfile. Pinned at v0.3.1020. |
| `Dockerfile builder stage` | `/app/web and /app/worker binaries` | `CGO_ENABLED=0 go build -o /app/web ./cmd/web` | ✓ WIRED | Lines 75-80 in Dockerfile. Both commands use `CGO_ENABLED=0 GOOS=linux`. |
| `docker-compose.prod.yaml web service` | `Dockerfile /app/web binary` | `command: /app/web` with `image: ${IMAGE}:${TAG}` | ✓ WIRED | Lines 43, 42 in docker-compose.prod.yaml. |
| `caddy service` | `deploy/Caddyfile` | `./deploy/Caddyfile:/etc/caddy/Caddyfile:ro` | ✓ WIRED | Line 76 in docker-compose.prod.yaml. |
| `Caddyfile` | `web:8080` | `reverse_proxy web:8080` | ✓ WIRED | Line 25 in deploy/Caddyfile. |

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| `go build ./cmd/web/...` exits 0 | `cd backend && go build ./cmd/web/...` | exit 0, no output | ✓ PASS |
| TestHealthz_OK passes (no pinger arg) | `go test ./internal/web/... -run TestHealthz_OK -v` | PASS (0.00s) | ✓ PASS |
| TestReadyz_OK passes (200, db:ok) | `go test ./internal/web/... -run TestReadyz_OK -v` | PASS (0.00s) | ✓ PASS |
| TestReadyz_Down passes (503, db:down) | `go test ./internal/web/... -run TestReadyz_Down -v` | PASS (0.00s) | ✓ PASS |
| Full test suite passes | `cd backend && go test ./... -count=1` | All packages ok, exit 0 | ✓ PASS |

### Probe Execution

No probes defined for this phase. Phase 7 produces infrastructure config files (Dockerfile, compose, Caddyfile) and Go code changes — probes are deferred to manual deployment verification (see Human Verification Required below).

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| DEPLOY-01 | 07-02 | Both binaries build into a single multi-stage Docker image | ✓ SATISFIED | `backend/Dockerfile` with 3 stages. Both /app/web and /app/worker compiled in builder stage, copied to runtime stage. |
| DEPLOY-02 | 07-02, 07-03 | Image runs on single VPS with env-injected config | ✓ SATISFIED | `backend/.env.example` complete. `docker-compose.prod.yaml` uses `env_file: .env.prod` for web/worker. README documents VM deploy workflow. |
| DEPLOY-03 | 07-01, 07-03 | Migrations run on deploy without manual intervention | ✓ SATISFIED | `db.RunMigrations(ctx, pool, assets.Migrations)` in `cmd/web/main.go` startup, before router construction. goose.Up() is idempotent. |
| DEPLOY-04 | 07-01 | /healthz and /readyz return appropriate status codes | ✓ SATISFIED | /healthz → 200 always (liveness). /readyz → 200+db:ok or 503+db:down (readiness with DB probe). Tests pass. |
| DEPLOY-05 | 07-03 | backend/README.md covers local dev, deploy, rollback, and incident triage | ✓ SATISFIED | README has `## Deploy` (first-time + routine), `## Rollback` (tag redeployment + break-glass schema rollback), `## Incident Runbook` (/readyz 503, Caddy TLS, log viewing, distroless debug). |

No orphaned requirements. All 5 DEPLOY-* requirements are claimed by plans and evidence found.

### Anti-Patterns Found

No debt markers (TBD, FIXME, XXX, TODO, HACK, PLACEHOLDER) found in any files modified by this phase. No stub return patterns found. No placeholder implementations.

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| — | — | None found | — | — |

### Human Verification Required

Three items require human verification involving Docker Engine and network infrastructure:

#### 1. Full Docker multi-stage build

**Test:** From inside `backend/`, run:
```
docker build -f Dockerfile -t xtablo-test . && echo "BUILD OK"
docker build -f Dockerfile --target builder -t xtablo-builder . && echo "BUILDER OK"
```
**Expected:** Both commands exit 0. The assets stage downloads Tailwind/HTMX/Sortable and compiles CSS. The builder stage runs `templ generate` and compiles both binaries with `CGO_ENABLED=0`. The final image contains only `/app/web` and `/app/worker` with no CMD.
**Why human:** Docker Engine is not installed on this development machine. The Dockerfile structure passes static analysis but the actual build pipeline (including external CDN downloads of Tailwind, HTMX, Sortable) cannot be executed here.

#### 2. docker compose config syntax validation

**Test:** From inside `backend/`, run:
```
docker compose -f docker-compose.prod.yaml config --quiet && echo "COMPOSE CONFIG OK"
```
**Expected:** Exit 0 — Docker validates all service definitions, volume declarations, environment variable references, and dependency conditions.
**Why human:** Docker Engine is not available in this environment.

#### 3. End-to-end production smoke test

**Test:** Follow the README First-time setup steps on a Hetzner VM (or equivalent host with a real domain):
1. Copy `backend/` to VM
2. Create `.env.prod` with real values (DOMAIN, S3 credentials, POSTGRES_PASSWORD, SESSION_SECRET)
3. `docker build -f Dockerfile -t ghcr.io/yourusername/xtablo:v0.1.0 .`
4. `export IMAGE=ghcr.io/yourusername/xtablo TAG=v0.1.0`
5. `docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d`
6. Verify: `curl https://<domain>/healthz` and `curl https://<domain>/readyz`

**Expected:**
- Stack starts: postgres becomes healthy first, then web and worker start
- goose.Up() runs migrations automatically at web startup (check `docker compose logs web`)
- `curl https://<domain>/healthz` → `{"status":"ok"}`
- `curl https://<domain>/readyz` → `{"status":"ok","db":"ok"}`
- Caddy obtains TLS certificate from Let's Encrypt (use staging endpoint to avoid rate limits on first test)
**Why human:** Actual VM, DNS, and Let's Encrypt provisioning cannot be tested programmatically.

### Gaps Summary

No gaps found. All must-haves are verified in the codebase. All 5 DEPLOY requirements have implementation evidence. The phase goal — "single-container deployment: Dockerfile, compose stack, Caddyfile, and operational runbook deployable with one command" — is achieved in the codebase.

Three items require human verification (Docker build, compose syntax validation, and a live deploy smoke test) before treating this phase as fully closed for production use.

---

_Verified: 2026-05-15_
_Verifier: Claude (gsd-verifier)_
