docs(07): create phase 7 plan — deploy v1 (3 plans, 3 waves)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-05-15 17:57:46 +02:00
parent 8fbe87295a
commit dbe9d493be
No known key found for this signature in database
4 changed files with 753 additions and 0 deletions

View file

@ -175,6 +175,12 @@ Plans:
**User-in-loop:** Approve the deploy target choice (Hetzner / Fly / Cloud Run) and the secret-management strategy (env vars vs `.env` file vs SOPS).
**Plans:** 3 plans
Plans:
- [ ] 07-01-PLAN.md — Wave 1: go:embed assets + goose RunMigrations + /healthz liveness split + /readyz readiness (DEPLOY-03, DEPLOY-04)
- [ ] 07-02-PLAN.md — Wave 2: multi-stage Dockerfile (assets + builder + distroless) + .env.example update (DEPLOY-01, DEPLOY-02)
- [ ] 07-03-PLAN.md — Wave 3: docker-compose.prod.yaml + deploy/Caddyfile + README runbook (DEPLOY-02..DEPLOY-05)
---
## Coverage
@ -195,3 +201,4 @@ Plans:
*Phase 3 plans added: 2026-05-15*
*Phase 4 plans added: 2026-05-15*
*Phase 5 plans added: 2026-05-15*
*Phase 7 plans added: 2026-05-15*

View file

@ -0,0 +1,253 @@
---
phase: 07-deploy-v1
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/assets/assets.go
- backend/internal/db/migrate.go
- backend/internal/web/handlers.go
- backend/internal/web/handlers_test.go
- backend/internal/web/router.go
- backend/cmd/web/main.go
autonomous: true
requirements:
- DEPLOY-03
- DEPLOY-04
must_haves:
truths:
- "GET /healthz returns 200 with no DB connection (pure liveness)"
- "GET /readyz returns 200 when DB is reachable and 503 when DB is down"
- "Static assets (tailwind.css, htmx.min.js, sortable.min.js) are served from embedded FS, not disk"
- "goose.Up() is called in cmd/web/main.go startup, before router construction, using embedded migrations"
- "Full Go test suite passes: go test ./..."
artifacts:
- path: "backend/assets/assets.go"
provides: "go:embed declarations for static/ and migrations/ directories"
contains: "//go:embed"
- path: "backend/internal/db/migrate.go"
provides: "RunMigrations function using pgx/v5/stdlib bridge and goose.Up()"
exports: ["RunMigrations"]
- path: "backend/internal/web/handlers.go"
provides: "split HealthzHandler (no pinger) and new ReadyzHandler (pinger)"
contains: "ReadyzHandler"
- path: "backend/internal/web/router.go"
provides: "NewRouter with fs.FS param, /readyz route, /healthz liveness route"
contains: "ReadyzHandler"
key_links:
- from: "backend/cmd/web/main.go"
to: "backend/internal/db/migrate.go"
via: "db.RunMigrations(ctx, pool, assets.Migrations)"
pattern: "RunMigrations"
- from: "backend/cmd/web/main.go"
to: "backend/assets/assets.go"
via: "assets.Static passed to web.NewRouter"
pattern: "assets\\.Static"
- from: "backend/internal/web/router.go"
to: "ReadyzHandler"
via: "r.Get(\"/readyz\", ReadyzHandler(pinger))"
pattern: "readyz"
---
## Phase Goal
**As a** developer, **I want to** ship the Go backend in a self-contained binary with embedded assets, embedded migrations, and correct health endpoints, **so that** the Docker image has zero runtime file dependencies and production deployments run migrations automatically.
<objective>
Deliver all Go code changes required before the Dockerfile can be built successfully:
1. Create `backend/assets/assets.go` with `//go:embed` for both `static/` and `migrations/` directories — this is the embed anchor for the entire phase (D-09, D-10).
2. Create `backend/internal/db/migrate.go` with `RunMigrations(ctx, pool, migrationsFS)` using the pgx/v5/stdlib bridge to goose.Up() (D-10, DEPLOY-03).
3. Refactor `backend/internal/web/handlers.go`: rename existing `HealthzHandler``ReadyzHandler`; add new no-pinger `HealthzHandler` (D-12, D-13, DEPLOY-04).
4. Update `backend/internal/web/router.go`: change `staticDir string``staticFS fs.FS`, register `/readyz` route with `ReadyzHandler`, keep `/healthz` with new liveness `HealthzHandler` (D-09, D-12, D-13).
5. Update `backend/cmd/web/main.go`: call `db.RunMigrations(ctx, pool, assets.Migrations)` after pool creation and pass `assets.Static` to `NewRouter` (D-10).
6. Update `backend/internal/web/handlers_test.go`: refactor `TestHealthz_OK` (no pinger), delete `TestHealthz_Down`, add `TestReadyz_OK` and `TestReadyz_Down`, fix all `NewRouter` call sites to pass `os.DirFS("./static")` (DEPLOY-04, Pitfall 8 in RESEARCH).
Purpose: Without this plan, `docker build` fails (assets not embedded, *_templ.go generated at build time but go:embed missing). Tests also fail after handler refactor if not updated together.
Output: All Go source changes committed; `cd backend && go test ./...` is green.
</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
</context>
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From backend/internal/web/router.go (current signature — will be changed):
```go
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler
```
From backend/internal/web/handlers.go (current — will be refactored):
```go
// Current (becoming ReadyzHandler):
func HealthzHandler(pinger Pinger) http.HandlerFunc
type Pinger interface { Ping(ctx context.Context) error }
```
From backend/internal/web/handlers_test.go (tests that call NewRouter):
```go
// Line 69, 84, 107 — all pass "./static" as second arg today:
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
```
From backend/cmd/web/main.go (current — will be modified):
```go
pool, err := db.NewPool(ctx, dsn) // line 64
q := sqlc.New(pool) // line 71 — goose.Up() goes between these
router := web.NewRouter(pool, "./static", deps, ...) // line 116 — changes to assets.Static
```
From backend/cmd/worker/main.go (rivermigrate pattern to mirror for goose.Up()):
```go
migrator, err := rivermigrate.New(riverpgxv5.New(pool), nil)
res, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil)
for _, v := range res.Versions {
slog.Info("river migration applied", "version", v.Version)
}
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: go:embed anchor, goose RunMigrations, and /healthz + /readyz handler split</name>
<files>
backend/assets/assets.go
backend/internal/db/migrate.go
backend/internal/web/handlers.go
backend/internal/web/handlers_test.go
</files>
<read_first>
- backend/internal/web/handlers.go (current HealthzHandler to rename, import list)
- backend/internal/web/handlers_test.go (TestHealthz_OK and TestHealthz_Down to refactor; stubPinger already defined)
- backend/cmd/worker/main.go (rivermigrate startup pattern to mirror for goose.Up() in migrate.go)
- backend/internal/db/ (existing package files to understand package name and any existing migrate.go)
- backend/migrations/ (directory list — all .sql files; embed.FS will embed these)
- backend/static/ (directory list — confirm no files starting with . or _; if found, use //go:embed all:static)
- backend/go.mod (confirm github.com/pressly/goose/v3 and github.com/jackc/pgx/v5 versions)
</read_first>
<behavior>
- TestHealthz_OK: `HealthzHandler()` (no args) returns 200 with body containing `"status":"ok"` but NOT `"db":"ok"`
- TestHealthz_Down: deleted (new HealthzHandler has no failure mode)
- TestReadyz_OK: `ReadyzHandler(stubPinger{err: nil})` returns 200 with body containing both `"status":"ok"` and `"db":"ok"`
- TestReadyz_Down: `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` returns 503 with body containing `"status":"degraded"` and `"db":"down"`
</behavior>
<action>
Create `backend/assets/assets.go` in package `assets`. The file must contain two `//go:embed` declarations: one for `static` (var Static embed.FS) and one for `migrations` (var Migrations embed.FS). IMPORTANT: the `//go:embed` path is relative to the .go file. Since `backend/assets/assets.go` is at `backend/assets/` and `static/` and `migrations/` are at `backend/`, the paths `../static` and `../migrations` would be invalid (go:embed cannot use `..`). Instead, create the file at `backend/embed.go` in package `main` — no, this conflicts with cmd/web. The correct approach per PATTERNS.md "Alternative (embed.go at module root)" is: create `backend/embed.go` as a standalone file in a new `package assets` would require a separate directory. The cleanest verified approach: place `assets.go` at `backend/assets/assets.go` and rely on the Go toolchain's embed path resolution from that file location. The `static/` directory is at `backend/static/`. From `backend/assets/assets.go`, the embed directive `//go:embed ../static` is invalid. Therefore use the RESEARCH-recommended alternative: create `backend/embed.go` (a file at the backend module root) in a new package. Since `backend/cmd/web/main.go` is in `package main`, `backend/embed.go` cannot also be `package main`. Create `backend/assets/assets.go` and symlink `static` and `migrations` inside `backend/assets/` — no symlinks in Docker builds.
**Correct approach (verified per RESEARCH Pitfall 1 and PATTERNS.md "Path verification required"):** Place the embed file at `backend/internal/assets/assets.go` — this does NOT help with `..` restriction either. The only valid approach without moving directories: place the `//go:embed` directive in a file that IS in the `backend/` directory. Create `backend/assets.go` as a file directly at the `backend/` module root, in a package named `assets`. But Go requires all .go files in the same directory to share a package name — and `backend/cmd/web/main.go` is in a subdirectory, not in `backend/` directly. Files in `backend/` like `backend/go.mod` don't constrain the package name. Check if any .go files exist directly in `backend/`; if not, a new package at `backend/assets/assets.go` with `//go:embed static migrations` is valid IF `static` and `migrations` are moved inside `backend/assets/` — which we cannot do without changing many paths.
**Final resolution:** Create the embed file as `backend/embed.go` in a dedicated package. Scan what `.go` files exist directly in `backend/` with `ls backend/*.go`. If none exist, create `backend/embed.go` with `package assets` — this is a new Go package at the module root. Import it in `cmd/web/main.go` as `"backend/assets"` (where the module is `module backend` per go.mod). This works because `backend/embed.go` is a sibling of `backend/static/` and `backend/migrations/`, so `//go:embed static` and `//go:embed migrations` resolve correctly.
Actually re-read RESEARCH: "the embed directive file must also be at `backend/` — either as `backend/embed.go`". Confirm module name by reading `backend/go.mod` first line. Create `backend/embed.go` with `package assets` containing:
- `//go:embed static``var Static embed.FS`
- `//go:embed migrations``var Migrations embed.FS`
If `backend/static/` contains files starting with `.` (check with `ls -la backend/static/`), use `//go:embed all:static` and `//go:embed all:migrations`.
Create `backend/internal/db/migrate.go` in `package db`. Import `database/sql`, `_ "github.com/jackc/pgx/v5/stdlib"`, `"github.com/pressly/goose/v3"`, `"github.com/jackc/pgx/v5/pgxpool"`, and `"embed"`. Implement `RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error` that: (1) extracts DSN via `pool.Config().ConnConfig.ConnString()`, (2) opens `sql.Open("pgx/v5", dsn)` + `defer db.Close()`, (3) calls `goose.SetBaseFS(migrationsFS)`, (4) calls `goose.SetDialect("postgres")`, (5) calls `goose.Up(db, "migrations")` and returns the error. Do NOT call `goose.SetTableName` — the production table uses the default `goose_db_version`.
In `backend/internal/web/handlers.go`: rename current `HealthzHandler(pinger Pinger) http.HandlerFunc``ReadyzHandler(pinger Pinger) http.HandlerFunc` (logic unchanged: 2s timeout, DB ping, 503 on error with `{"status":"degraded","db":"down"}`, 200 with `{"status":"ok","db":"ok"}`). Add new `HealthzHandler() http.HandlerFunc` that returns 200 immediately with `{"status":"ok"}` only (no DB ping, no context, no time import needed in this handler — but keep the imports that ReadyzHandler still needs).
In `backend/internal/web/handlers_test.go`: update `TestHealthz_OK` to call `HealthzHandler()` with no args; remove the `"db":"ok"` assertion (new liveness handler does not return db field); delete `TestHealthz_Down`; add `TestReadyz_OK` that calls `ReadyzHandler(stubPinger{err: nil})` and asserts 200 + `"status":"ok"` + `"db":"ok"`; add `TestReadyz_Down` that calls `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` and asserts 503 + `"status":"degraded"` + `"db":"down"`. The `stubPinger` struct at the top of the file is unchanged.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web/... -run "TestHealthz|TestReadyz" -v -count=1</automated>
</verify>
<done>
TestHealthz_OK passes (200, no pinger arg, body has "status":"ok" but NOT "db":"ok"). TestReadyz_OK passes (200, DB ok, body has both "status":"ok" and "db":"ok"). TestReadyz_Down passes (503, body has "status":"degraded" and "db":"down"). TestHealthz_Down is deleted. backend/embed.go exists with //go:embed directives for both static and migrations. backend/internal/db/migrate.go exports RunMigrations.
</done>
</task>
<task type="auto">
<name>Task 2: Wire goose.Up() and embed.FS into cmd/web/main.go and fix NewRouter signature + all test call sites</name>
<files>
backend/internal/web/router.go
backend/cmd/web/main.go
backend/internal/web/handlers_test.go
</files>
<read_first>
- backend/internal/web/router.go (full file — change staticDir string → staticFS fs.FS, add /readyz route, update /healthz)
- backend/cmd/web/main.go (full file — add RunMigrations call after pool creation, change NewRouter call to pass assets.Static)
- backend/internal/web/handlers_test.go (all NewRouter call sites that pass "./static" — need os.DirFS("./static"))
- backend/embed.go (created in Task 1 — verify package name and exported var names)
- backend/internal/db/migrate.go (created in Task 1 — verify RunMigrations signature)
- backend/go.mod (confirm module name for import path: "backend/assets" or what the actual package path is)
</read_first>
<action>
In `backend/internal/web/router.go`: change the `NewRouter` function signature — replace the `staticDir string` parameter with `staticFS fs.FS` (add `"io/fs"` to imports, remove no-longer-needed static path logic). Replace the static file serving lines (currently `fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))` and `r.Get("/static/*", fs.ServeHTTP)`) with the embed.FS pattern: `sub, err := fs.Sub(staticFS, "static"); if err != nil { panic(...) }; fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(sub))); r.Get("/static/*", fileServer.ServeHTTP)`. Change the `/healthz` route from `r.Get("/healthz", HealthzHandler(pinger))` to `r.Get("/healthz", HealthzHandler())` (liveness, no pinger, per D-12). Add `r.Get("/readyz", ReadyzHandler(pinger))` immediately after (per D-13). Variable name conflict: the local variable `fs` used for the file server will conflict with the `"io/fs"` package import alias. Rename the local variable (e.g., `fileHandler`) to avoid the shadowing.
In `backend/cmd/web/main.go`: add import for the embed package containing `assets.Static` and `assets.Migrations` — check the package path from `backend/go.mod` module declaration (likely `backend` module, so the import is `"backend"` if `embed.go` uses `package assets` but lives in the `backend/` directory... re-check: Go package = the `package` declaration in the .go file, not the directory. If `backend/embed.go` has `package assets`, then `cmd/web/main.go` imports `"backend"` with an alias — no, the import path is the directory path relative to module root. The `backend/embed.go` file lives in the directory `backend/`, and the module is `module backend` (check go.mod). So the import path for files in `backend/` directly is `"backend"`. But the package name would be `assets` (or whatever `embed.go` declares). You would import: `import assets "backend"` or `import "backend"` and refer to `backend.Static`. To avoid ambiguity, use `package main` for `backend/embed.go` is not possible (cmd/web is also main). The cleanest solution: name the package in `backend/embed.go` as `assets` and import it in cmd/web as `import "backend"` with local name `assets`. Actually: `import assets "backend"` does NOT work — you cannot rename a package import to differ from the last element of the path like that conventionally. The actual import path for a file in the `backend/` directory with `module backend` is just `"backend"`, and the package name is whatever the file declares. So `import "backend"` makes the identifier `assets.Static` available as `backend.Static` (using the package declaration name, not the import path). To make this ergonomic: declare `package assets` in `backend/embed.go` and import with alias: `import assets "backend"` — this IS valid Go; you can alias any import path. Then use `assets.Static` and `assets.Migrations` in main.go.
Add to `cmd/web/main.go` after `pool, err := db.NewPool(ctx, dsn)` (after the nil-check block at line 64-69) and before `q := sqlc.New(pool)` (line 71): call `db.RunMigrations(ctx, pool, assets.Migrations)` with the same slog.Error + os.Exit(1) pattern used for other startup failures. Also log each applied migration with `slog.Info("goose migration applied")` if you want to mirror the worker, but the minimum is just error handling. Change `web.NewRouter(pool, "./static", ...)` to `web.NewRouter(pool, assets.Static, ...)`.
In `backend/internal/web/handlers_test.go`: find all call sites that pass `"./static"` as the second argument to `NewRouter` (lines 69, 84, 107 based on file inspection) and change them to `os.DirFS("./static")`. Add `"os"` to the test imports if not already present.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./cmd/web/... && go test ./... -count=1</automated>
</verify>
<done>
`go build ./cmd/web/...` succeeds. `go test ./...` is green (all tests pass including updated TestHealthz_OK, new TestReadyz_OK/Down, and all router-level tests with os.DirFS). NewRouter signature uses fs.FS for static assets. /readyz route is registered. cmd/web/main.go calls db.RunMigrations before constructing the router. No import errors or compilation failures.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| External → /healthz and /readyz | These routes are public (no auth required). Attackers can probe them. |
| Binary → embedded filesystem | go:embed bakes assets into binary at build time; no runtime file access possible. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-01 | Information Disclosure | /healthz and /readyz response bodies | mitigate | Return only `{"status":"ok"}` or `{"status":"degraded","db":"down"}` — no version strings, no stack traces, no DSN fragments |
| T-07-02 | Information Disclosure | goose_db_version table default name | accept | Table name is internal schema metadata; not exposed via any API endpoint; low risk |
| T-07-03 | Denial of Service | goose.Up() blocks startup until complete | accept | Migrations are idempotent and fast after initial apply; startup delay is bounded; acceptable trade-off for D-10 |
| T-07-04 | Tampering | go:embed includes files at build time | accept | Embedded FS is read-only at runtime; cannot be tampered with without rebuilding the binary |
</threat_model>
<verification>
Full suite after both tasks:
```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1
```
All tests green. No compilation errors. Verify embed works:
```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./cmd/web/... && ls -la cmd/web/web 2>/dev/null || go build -o /tmp/xtablo-web ./cmd/web && ls -la /tmp/xtablo-web
```
</verification>
<success_criteria>
1. `cd backend && go test ./... -count=1` exits 0 with all tests passing
2. `cd backend && go build ./cmd/web/...` exits 0 (binary compiles with embedded assets and migrations)
3. `grep -r "ReadyzHandler" backend/internal/web/router.go` returns a match
4. `grep -r "RunMigrations" backend/cmd/web/main.go` returns a match
5. `backend/embed.go` exists and contains `//go:embed static` and `//go:embed migrations`
6. `backend/internal/db/migrate.go` exists and exports `RunMigrations`
7. TestHealthz_OK passes without a pinger argument; TestHealthz_Down is deleted; TestReadyz_OK and TestReadyz_Down exist and pass
</success_criteria>
<output>
After completion, create `.planning/phases/07-deploy-v1/07-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,197 @@
---
phase: 07-deploy-v1
plan: 02
type: execute
wave: 2
depends_on:
- 07-01
files_modified:
- backend/Dockerfile
- backend/.env.example
autonomous: true
requirements:
- DEPLOY-01
- DEPLOY-02
must_haves:
truths:
- "A single Dockerfile builds both /app/web and /app/worker binaries in one image"
- "The builder stage runs templ generate before go build (gitignored *_templ.go files)"
- "The final runtime image is distroless/static-debian12:nonroot with CGO_ENABLED=0 binaries"
- "The image has no CMD — docker-compose overrides command: per service"
- ".env.example documents all production env vars including S3/R2, DOMAIN, and MAX_UPLOAD_SIZE_MB"
artifacts:
- path: "backend/Dockerfile"
provides: "3-stage multi-stage Docker build: assets, builder, runtime"
contains: "distroless/static-debian12"
- path: "backend/.env.example"
provides: "documented env vars for both dev and production including S3_ENDPOINT, S3_BUCKET, DOMAIN"
contains: "S3_ENDPOINT"
key_links:
- from: "Dockerfile builder stage"
to: "templ generate"
via: "RUN go install templ@v0.3.1020 && templ generate"
pattern: "templ generate"
- from: "Dockerfile builder stage"
to: "/app/web and /app/worker binaries"
via: "CGO_ENABLED=0 go build -o /app/web ./cmd/web && go build -o /app/worker ./cmd/worker"
pattern: "CGO_ENABLED=0"
---
<objective>
Deliver the multi-stage Dockerfile (D-07, DEPLOY-01) and the updated `.env.example` (D-05, DEPLOY-02):
1. Create `backend/Dockerfile` with three stages: (a) `assets` stage downloads/builds Tailwind CSS + HTMX JS + Sortable.js; (b) `builder` stage copies assets, runs `templ generate`, and compiles both binaries with CGO_ENABLED=0; (c) `runtime` stage copies only the two binaries into a distroless base.
2. Update `backend/.env.example` to add S3/R2 vars, MAX_UPLOAD_SIZE_MB, and a DOMAIN comment — these vars are referenced in docker-compose.prod.yaml and the runbook.
Purpose: The Dockerfile is the deliverable for DEPLOY-01. Without it, there is no production image. The .env.example update ensures operators have a complete reference for what `.env.prod` must contain on the Hetzner VM.
Output: `backend/Dockerfile` and updated `backend/.env.example` committed. The Docker builder stage can be verified by running `docker build --target builder`.
</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-01-SUMMARY.md
</context>
<interfaces>
<!-- Key decisions and pinned versions from codebase. -->
From backend/justfile (pinned tool versions — must match exactly in Dockerfile):
- templ_version := "v0.3.1020"
- tailwind_version := "v4.0.0" → Tailwind standalone CLI URL: https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.0/tailwindcss-linux-x64
- htmx_version := "2" → HTMX URL: https://unpkg.com/htmx.org@2/dist/htmx.min.js
- sortable_version := "1.15.7" → Sortable URL: https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js
- Go version: 1.26 (from go.mod `go 1.26.1`) → builder base: golang:1.26-alpine
From RESEARCH.md (locked decisions):
- D-07: single Dockerfile, two binaries /app/web and /app/worker
- D-08: no CMD in Dockerfile — compose overrides with command: per service
- D-09: all assets embedded; no volume mounts
- Runtime base: gcr.io/distroless/static-debian12:nonroot
From backend/.env.example (current contents — vars to preserve and add to):
- DATABASE_URL, TEST_DATABASE_URL, SESSION_SECRET, PORT=8080, ENV=development
- Missing (to add): S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB, DOMAIN
From RESEARCH Anti-Patterns:
- CGO_ENABLED=0 is mandatory (distroless/static has no C libs)
- No CMD in final stage (compose overrides)
- Use go build cache: --mount=type=cache,target=/root/.cache/go-build
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Multi-stage Dockerfile for web + worker binaries</name>
<files>
backend/Dockerfile
</files>
<read_first>
- backend/justfile (lines 1-40 — pinned versions for tailwind, htmx, sortable, templ; also check if tailwind reads from templates/ directory for content scanning)
- backend/tailwind.input.css (exists at backend/tailwind.input.css — verify path for COPY in assets stage)
- backend/templates/ (directory exists — needed by Tailwind for content scanning in assets stage)
- backend/go.mod (first line — confirms module name "backend" and go version "1.26.1")
- backend/cmd/web/ (verify main.go is the entry point for /app/web binary)
- backend/cmd/worker/ (verify main.go is the entry point for /app/worker binary)
- backend/static/ (ls -la — check for files starting with . or _ that would be excluded by go:embed without "all:" prefix)
</read_first>
<action>
Create `backend/Dockerfile` at the `backend/` directory root (sibling to go.mod). The Dockerfile context root will be `backend/` (i.e., `docker build -f backend/Dockerfile backend/` from the repo root, or `docker build .` from inside `backend/`).
Stage 1 — named `assets`, base `node:20-alpine`: WORKDIR /build. Run `apk add --no-cache curl`. Create `static/` dir with `mkdir -p static`. Download Tailwind standalone CLI to `/usr/local/bin/tailwindcss` from the pinned URL (v4.0.0 linux-x64) and `chmod +x`. Download HTMX: `curl -sSL -o static/htmx.min.js "https://unpkg.com/htmx.org@2/dist/htmx.min.js"`. Download Sortable.js: `curl -sSL -o static/sortable.min.js "https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js"`. COPY the Tailwind input and templates: `COPY tailwind.input.css .` and `COPY templates/ templates/`. Run Tailwind: `tailwindcss -i tailwind.input.css -o static/tailwind.css --minify`.
Stage 2 — named `builder`, base `golang:1.26-alpine`: WORKDIR /app. COPY go.mod go.sum ./. RUN go mod download. COPY . . (copies entire backend/ context). COPY --from=assets /build/static ./static (overwrites static/ with freshly-built assets). Run templ generate at the pinned version: `RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate`. Then build both binaries with build cache mounts and CGO_ENABLED=0: `RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o /app/web ./cmd/web`. `RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o /app/worker ./cmd/worker`.
Stage 3 — runtime, base `gcr.io/distroless/static-debian12:nonroot`: COPY --from=builder /app/web /app/web. COPY --from=builder /app/worker /app/worker. EXPOSE 8080. No CMD (per D-08 — docker-compose.prod.yaml sets `command:` per service). No ENTRYPOINT.
Add a comment at the top of the Dockerfile explaining the three-stage structure and referencing D-07, D-08, D-09.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && docker build -f Dockerfile --target builder -t xtablo-builder-test . 2>&1 | tail -5</automated>
</verify>
<done>
backend/Dockerfile exists with three stages (assets, builder, runtime). `docker build --target builder` succeeds (both web and worker binaries compiled). Final stage uses gcr.io/distroless/static-debian12:nonroot. No CMD instruction in the final stage. templ generate runs at v0.3.1020. CGO_ENABLED=0 on both go build commands.
</done>
</task>
<task type="auto">
<name>Task 2: Update .env.example with S3/R2, DOMAIN, and MAX_UPLOAD_SIZE_MB vars</name>
<files>
backend/.env.example
</files>
<read_first>
- backend/.env.example (full file — current content to preserve before adding new vars)
- backend/cmd/web/main.go (S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB — verify exact env var names as read by main.go)
</read_first>
<action>
Append to `backend/.env.example` (after the existing ENV=development line) a new section with these vars and comments:
S3_ENDPOINT — with example value `http://localhost:9000` (MinIO dev value) and a comment: "In production (Cloudflare R2): https://<account-id>.r2.cloudflarestorage.com (D-06)". S3_BUCKET with example `xtablo-dev`. S3_REGION with example `us-east-1` (R2 uses auto or a region token; keep us-east-1 as default). S3_ACCESS_KEY with example `minioadmin` (dev value). S3_SECRET_KEY with example `minioadmin` (dev value). S3_USE_PATH_STYLE with example `true` and comment: "true for MinIO (path-style); set to false for Cloudflare R2 (virtual-hosted)". MAX_UPLOAD_SIZE_MB with example `25` and comment: "Maximum upload size in megabytes. Default 25 if unset." DOMAIN — commented out with `# DOMAIN=app.yourdomain.com` and comment: "Production domain for Caddy TLS (only used in docker-compose.prod.yaml context, D-04)".
Do NOT remove or change any existing vars. Keep TEST_DATABASE_URL (used by integration tests). Update the comment above TEST_DATABASE_URL to clarify it is dev/test only and should not appear in the production .env.prod file.
</action>
<verify>
<automated>grep -c "S3_ENDPOINT\|S3_BUCKET\|S3_ACCESS_KEY\|S3_SECRET_KEY\|S3_REGION\|S3_USE_PATH_STYLE\|MAX_UPLOAD_SIZE_MB\|DOMAIN" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/.env.example</automated>
</verify>
<done>
backend/.env.example contains all 8 new var entries (S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB, DOMAIN). All original vars (DATABASE_URL, TEST_DATABASE_URL, SESSION_SECRET, PORT, ENV) are preserved. The grep count returns 8.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Docker build context | Source code + assets COPY'd into builder; .env files must NOT be COPY'd |
| Runtime image → host env | Secrets injected via env_file at compose runtime; never baked into image layers |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-05 | Information Disclosure | Docker image layers | mitigate | No `COPY .env*` in Dockerfile; secrets arrive only via `env_file:` in compose at runtime (D-05). Add .env* to .dockerignore if one exists, or ensure no .env files exist in backend/ at build time |
| T-07-06 | Information Disclosure | .env.example committed to git | accept | .env.example contains only placeholder/dev values (no real secrets); this is intentional documentation |
| T-07-07 | Elevation of Privilege | distroless nonroot image | mitigate | `:nonroot` tag runs as uid 65532 (nonroot user); binary cannot write to filesystem; reduces blast radius if exploited |
| T-07-08 | Denial of Service | external CDN downloads in assets stage | accept | Tailwind, HTMX, Sortable.js downloads from unpkg/cdnjs during build; network failure fails the build but not production. Mitigation path: pin via checksums or vendor; accept for v1 |
</threat_model>
<verification>
Verify builder stage compiles:
```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && docker build -f Dockerfile --target builder -t xtablo-builder-test . && echo "BUILDER OK"
```
Verify .env.example completeness:
```
grep -E "S3_ENDPOINT|S3_BUCKET|S3_REGION|S3_ACCESS_KEY|S3_SECRET_KEY|S3_USE_PATH_STYLE|MAX_UPLOAD_SIZE_MB|DOMAIN" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/.env.example
```
</verification>
<success_criteria>
1. `backend/Dockerfile` exists with three stages named `assets`, `builder`, and final runtime
2. `docker build -f backend/Dockerfile --target builder backend/` succeeds (both binaries compile)
3. Final stage uses `gcr.io/distroless/static-debian12:nonroot`
4. Dockerfile contains `templ generate` at version `v0.3.1020`
5. Both `go build` commands have `CGO_ENABLED=0 GOOS=linux`
6. No `CMD` instruction in the final stage
7. `backend/.env.example` contains `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`, `S3_USE_PATH_STYLE`, `MAX_UPLOAD_SIZE_MB`, and `# DOMAIN=`
8. All original vars in `.env.example` are preserved
</success_criteria>
<output>
After completion, create `.planning/phases/07-deploy-v1/07-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,296 @@
---
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:
- "docker-compose.prod.yaml defines postgres, web, worker, and caddy services using the single image"
- "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"
- "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)"
- "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>