From dbe9d493bef78a40499d2cbb5567d0eab82b11f2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 17:57:46 +0200 Subject: [PATCH] =?UTF-8?q?docs(07):=20create=20phase=207=20plan=20?= =?UTF-8?q?=E2=80=94=20deploy=20v1=20(3=20plans,=203=20waves)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .planning/ROADMAP.md | 7 + .planning/phases/07-deploy-v1/07-01-PLAN.md | 253 +++++++++++++++++ .planning/phases/07-deploy-v1/07-02-PLAN.md | 197 +++++++++++++ .planning/phases/07-deploy-v1/07-03-PLAN.md | 296 ++++++++++++++++++++ 4 files changed, 753 insertions(+) create mode 100644 .planning/phases/07-deploy-v1/07-01-PLAN.md create mode 100644 .planning/phases/07-deploy-v1/07-02-PLAN.md create mode 100644 .planning/phases/07-deploy-v1/07-03-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 3fba5d5..be97212 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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* diff --git a/.planning/phases/07-deploy-v1/07-01-PLAN.md b/.planning/phases/07-deploy-v1/07-01-PLAN.md new file mode 100644 index 0000000..dcad86f --- /dev/null +++ b/.planning/phases/07-deploy-v1/07-01-PLAN.md @@ -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. + + +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. + + + +@/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 + + + +@/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 + + + + + +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) +} +``` + + + + + + Task 1: go:embed anchor, goose RunMigrations, and /healthz + /readyz handler split + + backend/assets/assets.go + backend/internal/db/migrate.go + backend/internal/web/handlers.go + backend/internal/web/handlers_test.go + + + - 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) + + + - 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"` + + + 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. + + + cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web/... -run "TestHealthz|TestReadyz" -v -count=1 + + + 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. + + + + + Task 2: Wire goose.Up() and embed.FS into cmd/web/main.go and fix NewRouter signature + all test call sites + + backend/internal/web/router.go + backend/cmd/web/main.go + backend/internal/web/handlers_test.go + + + - 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) + + + 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. + + + cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./cmd/web/... && go test ./... -count=1 + + + `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. + + + + + + +## 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 | + + + +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 +``` + + + +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 + + + +After completion, create `.planning/phases/07-deploy-v1/07-01-SUMMARY.md` + diff --git a/.planning/phases/07-deploy-v1/07-02-PLAN.md b/.planning/phases/07-deploy-v1/07-02-PLAN.md new file mode 100644 index 0000000..76bcee6 --- /dev/null +++ b/.planning/phases/07-deploy-v1/07-02-PLAN.md @@ -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" +--- + + +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`. + + + +@/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 + + + +@/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 + + + + + +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 + + + + + + Task 1: Multi-stage Dockerfile for web + worker binaries + + backend/Dockerfile + + + - 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) + + + 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. + + + cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && docker build -f Dockerfile --target builder -t xtablo-builder-test . 2>&1 | tail -5 + + + 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. + + + + + Task 2: Update .env.example with S3/R2, DOMAIN, and MAX_UPLOAD_SIZE_MB vars + + backend/.env.example + + + - 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) + + + 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://.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. + + + 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 + + + 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. + + + + + + +## 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 | + + + +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 +``` + + + +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 + + + +After completion, create `.planning/phases/07-deploy-v1/07-02-SUMMARY.md` + diff --git a/.planning/phases/07-deploy-v1/07-03-PLAN.md b/.planning/phases/07-deploy-v1/07-03-PLAN.md new file mode 100644 index 0000000..0a06d8e --- /dev/null +++ b/.planning/phases/07-deploy-v1/07-03-PLAN.md @@ -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" +--- + + +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. + + + +@/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 + + + +@/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 + + + + + +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 + + + + + + Task 1: docker-compose.prod.yaml and deploy/Caddyfile + + backend/docker-compose.prod.yaml + backend/deploy/Caddyfile + + + - 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) + + + 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. + + + cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && docker compose -f docker-compose.prod.yaml config --quiet && echo "COMPOSE CONFIG OK" + + + `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`. + + + + + Task 2: Extend backend/README.md with Deploy, Rollback, and Incident Runbook sections + + backend/README.md + + + - 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) + + + 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: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 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: busybox sh`. + + + grep -c "## Deploy\|## Rollback\|## Incident Runbook" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/README.md + + + `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. + + + + + + +## 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 | + + + +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 +``` + + + +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) + + + +After completion, create `.planning/phases/07-deploy-v1/07-03-SUMMARY.md` +