15 KiB
| phase | verified | status | score | overrides_applied | human_verification | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-deploy-v1 | 2026-05-15T00:00:00Z | human_needed | 5/5 must-haves verified | 0 |
|
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):
- Copy
backend/to VM - Create
.env.prodwith real values (DOMAIN, S3 credentials, POSTGRES_PASSWORD, SESSION_SECRET) docker build -f Dockerfile -t ghcr.io/yourusername/xtablo:v0.1.0 .export IMAGE=ghcr.io/yourusername/xtablo TAG=v0.1.0docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d- Verify:
curl https://<domain>/healthzandcurl 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)