From 8ae83f6c503536978c1096c8fbce4bec956cbb9f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 18:06:08 +0200 Subject: [PATCH] docs(07): create phase plan --- .planning/ROADMAP.md | 5 + .planning/STATE.md | 10 +- .planning/phases/07-deploy-v1/07-01-PLAN.md | 49 +- .planning/phases/07-deploy-v1/07-02-PLAN.md | 6 +- .planning/phases/07-deploy-v1/07-03-PLAN.md | 8 +- .planning/phases/07-deploy-v1/07-PATTERNS.md | 590 +++++++++++++++++++ .planning/phases/07-deploy-v1/07-RESEARCH.md | 21 +- 7 files changed, 637 insertions(+), 52 deletions(-) create mode 100644 .planning/phases/07-deploy-v1/07-PATTERNS.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index be97212..847486f 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -177,8 +177,13 @@ Plans: **Plans:** 3 plans Plans: +**Wave 1** - [ ] 07-01-PLAN.md — Wave 1: go:embed assets + goose RunMigrations + /healthz liveness split + /readyz readiness (DEPLOY-03, DEPLOY-04) + +**Wave 2** *(blocked on Wave 1 completion)* - [ ] 07-02-PLAN.md — Wave 2: multi-stage Dockerfile (assets + builder + distroless) + .env.example update (DEPLOY-01, DEPLOY-02) + +**Wave 3** *(blocked on Wave 2 completion)* - [ ] 07-03-PLAN.md — Wave 3: docker-compose.prod.yaml + deploy/Caddyfile + README runbook (DEPLOY-02..DEPLOY-05) --- diff --git a/.planning/STATE.md b/.planning/STATE.md index de8519e..5bbe199 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,13 +3,13 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone status: ready_to_plan -last_updated: "2026-05-15T14:38:52.742Z" +last_updated: "2026-05-15T16:06:01.313Z" progress: total_phases: 7 - completed_phases: 5 - total_plans: 25 - completed_plans: 24 - percent: 96 + completed_phases: 6 + total_plans: 28 + completed_plans: 25 + percent: 89 --- # STATE diff --git a/.planning/phases/07-deploy-v1/07-01-PLAN.md b/.planning/phases/07-deploy-v1/07-01-PLAN.md index dcad86f..49725e9 100644 --- a/.planning/phases/07-deploy-v1/07-01-PLAN.md +++ b/.planning/phases/07-deploy-v1/07-01-PLAN.md @@ -5,7 +5,7 @@ type: execute wave: 1 depends_on: [] files_modified: - - backend/assets/assets.go + - backend/embed.go - backend/internal/db/migrate.go - backend/internal/web/handlers.go - backend/internal/web/handlers_test.go @@ -18,13 +18,13 @@ requirements: 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" + - "D-12: GET /healthz returns 200 with no DB connection (pure liveness)" + - "D-13: GET /readyz returns 200 when DB is reachable and 503 when DB is down" + - "D-09: Static assets (tailwind.css, htmx.min.js, sortable.min.js) are served from embedded FS, not disk" + - "D-10: 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" + - path: "backend/embed.go" provides: "go:embed declarations for static/ and migrations/ directories" contains: "//go:embed" - path: "backend/internal/db/migrate.go" @@ -42,8 +42,8 @@ must_haves: 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" + to: "backend/embed.go" + via: "assets.Static passed to web.NewRouter (import assets \"backend\")" pattern: "assets\\.Static" - from: "backend/internal/web/router.go" to: "ReadyzHandler" @@ -58,7 +58,7 @@ must_haves: 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). +1. Create `backend/embed.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). @@ -125,7 +125,7 @@ for _, v := range res.Versions { Task 1: go:embed anchor, goose RunMigrations, and /healthz + /readyz handler split - backend/assets/assets.go + backend/embed.go backend/internal/db/migrate.go backend/internal/web/handlers.go backend/internal/web/handlers_test.go @@ -146,29 +146,24 @@ for _, v := range res.Versions { - 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. + **Verified approach (no ambiguity — read RESEARCH.md Open Questions (RESOLVED) before starting):** - **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. + Create `backend/embed.go` in `package assets`. This file lives at the module root (`backend/`), making `static/` and `migrations/` siblings — so `//go:embed` directives resolve without any `..` paths. Module is `module backend` (verified in `go.mod`). No other .go files exist at `backend/` root, so no package name conflict. Declare two exported vars: + - `//go:embed all:static` → `var Static embed.FS` (use `all:` prefix because `backend/static/.gitkeep` exists) + - `//go:embed migrations` → `var Migrations embed.FS` (no hidden files in migrations/) + Import in `cmd/web/main.go` as `import assets "backend"` — aliasing the import path to the declared package name `assets`. - **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. + Create `backend/internal/db/migrate.go` in `package db`. 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 sqlDB.Close()`, (3) calls `goose.SetBaseFS(migrationsFS)`, (4) calls `goose.SetDialect("postgres")`, (5) calls `goose.Up(sqlDB, "migrations")` and returns the error. Required imports: `database/sql`, `_ "github.com/jackc/pgx/v5/stdlib"`, `"github.com/pressly/goose/v3"`, `"github.com/jackc/pgx/v5/pgxpool"`, `"embed"`, `"context"`. Do NOT set a custom table name — default `goose_db_version` is correct. - 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` + In `backend/internal/web/handlers.go`: rename existing `HealthzHandler(pinger Pinger) http.HandlerFunc` → `ReadyzHandler(pinger Pinger) http.HandlerFunc` (logic unchanged: 2s context timeout, `db.Ping()`, 503 + `{"status":"degraded","db":"down"}` on error, 200 + `{"status":"ok","db":"ok"}` on success). Add new `HealthzHandler() http.HandlerFunc` that returns 200 + `{"status":"ok"}` immediately with no DB ping and no pinger argument. - 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. + In `backend/internal/web/handlers_test.go`: update `TestHealthz_OK` to call `HealthzHandler()` with no args and assert 200 + body contains `"status":"ok"` but NOT `"db":"ok"`; delete `TestHealthz_Down`; add `TestReadyz_OK` calling `ReadyzHandler(stubPinger{err: nil})` → 200 + `"status":"ok"` + `"db":"ok"`; add `TestReadyz_Down` calling `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` → 503 + `"status":"degraded"` + `"db":"down"`. The `stubPinger` struct 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. + 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 in `package assets` with `//go:embed all:static` and `//go:embed migrations`. backend/internal/db/migrate.go exports RunMigrations. @@ -190,9 +185,9 @@ for _, v := range res.Versions { 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. + In `backend/cmd/web/main.go`: add `import assets "backend"` — this aliases the import path `"backend"` (module root files) to the local identifier `assets`, which matches the `package assets` declaration in `backend/embed.go`. This is valid Go aliased import syntax. - 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, ...)`. + After `pool, err := db.NewPool(ctx, dsn)` and its nil-check block, before `q := sqlc.New(pool)`: add a call to `db.RunMigrations(ctx, pool, assets.Migrations)`. Use the same `slog.Error(...) + os.Exit(1)` error-handling pattern already used for `db.NewPool`. 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. @@ -243,7 +238,7 @@ cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go 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` +5. `backend/embed.go` exists in `package assets`, contains `//go:embed all: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 diff --git a/.planning/phases/07-deploy-v1/07-02-PLAN.md b/.planning/phases/07-deploy-v1/07-02-PLAN.md index 76bcee6..884a047 100644 --- a/.planning/phases/07-deploy-v1/07-02-PLAN.md +++ b/.planning/phases/07-deploy-v1/07-02-PLAN.md @@ -15,11 +15,11 @@ requirements: must_haves: truths: - - "A single Dockerfile builds both /app/web and /app/worker binaries in one image" + - "D-07: 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" + - "D-08: The image has no CMD — docker-compose overrides command: per service" + - "D-05/D-06: .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" diff --git a/.planning/phases/07-deploy-v1/07-03-PLAN.md b/.planning/phases/07-deploy-v1/07-03-PLAN.md index 0a06d8e..187132e 100644 --- a/.planning/phases/07-deploy-v1/07-03-PLAN.md +++ b/.planning/phases/07-deploy-v1/07-03-PLAN.md @@ -18,12 +18,12 @@ requirements: 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" + - "D-01/D-02/D-03: docker-compose.prod.yaml defines postgres, web, worker, and caddy services for Hetzner VM" + - "D-08: The web service has command: /app/web and depends_on postgres with service_healthy condition" - "The worker service has command: /app/worker with the same depends_on condition" - - "Caddy service mounts deploy/Caddyfile and has persistent caddy_data and caddy_config named volumes" + - "D-04: Caddy service mounts deploy/Caddyfile and has persistent caddy_data and caddy_config named volumes" - "Postgres service has no ports: directive (internal-only, per RESEARCH Pitfall 5)" - - "backend/README.md documents first-time deploy, routine deploy, rollback, and incident triage sections" + - "D-11: backend/README.md documents first-time deploy, routine deploy, rollback, and incident triage sections" - "backend/deploy/Caddyfile uses {$DOMAIN} env var interpolation for the site block" artifacts: - path: "backend/docker-compose.prod.yaml" diff --git a/.planning/phases/07-deploy-v1/07-PATTERNS.md b/.planning/phases/07-deploy-v1/07-PATTERNS.md new file mode 100644 index 0000000..b529ef1 --- /dev/null +++ b/.planning/phases/07-deploy-v1/07-PATTERNS.md @@ -0,0 +1,590 @@ +# Phase 7: Deploy v1 - Pattern Map + +**Mapped:** 2026-05-15 +**Files analyzed:** 8 +**Analogs found:** 6 / 8 + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `backend/Dockerfile` | config | batch (build pipeline) | `apps/api/Dockerfile` (TS monorepo) | partial — different language, same multi-stage concept | +| `backend/docker-compose.prod.yaml` | config | request-response | `backend/compose.yaml` | exact — same compose v2 syntax, same postgres service | +| `backend/deploy/Caddyfile` | config | request-response | none in codebase | no analog | +| `backend/cmd/web/main.go` (modify) | config | request-response | `backend/cmd/worker/main.go` | exact — rivermigrate startup is the model for goose.Up() | +| `backend/internal/web/handlers.go` (modify) | handler | request-response | `backend/internal/web/handlers.go` itself | self — refactor existing HealthzHandler, add ReadyzHandler | +| `backend/internal/web/handlers_test.go` (modify) | test | request-response | `backend/internal/web/handlers_test.go` itself | self — extend existing TestHealthz_* tests, add TestReadyz_* | +| `backend/internal/web/router.go` (modify) | config | request-response | `backend/internal/web/router.go` itself | self — add /readyz route, update /healthz, change staticDir to fs.FS | +| `backend/assets/assets.go` (new) | utility | batch (embed) | none in codebase | no analog — first go:embed usage in this codebase | +| `backend/internal/db/migrate.go` (new) | utility | batch (migration) | `backend/cmd/worker/main.go` lines 54–68 | role-match — rivermigrate startup pattern | +| `backend/README.md` (modify) | config | — | `backend/README.md` itself | self — extend existing structure | +| `backend/.env.example` (modify) | config | — | `backend/.env.example` itself | self — add R2/prod vars | + +## Pattern Assignments + +--- + +### `backend/docker-compose.prod.yaml` (config, request-response) + +**Analog:** `backend/compose.yaml` + +**Postgres service pattern** (compose.yaml lines 1–18): +```yaml +services: + postgres: + image: postgres:16-alpine + container_name: xtablo-backend-postgres + restart: unless-stopped + environment: + POSTGRES_DB: xtablo + POSTGRES_USER: xtablo + POSTGRES_PASSWORD: xtablo + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"] + interval: 5s + timeout: 5s + retries: 10 +``` + +**Key differences in prod compose vs dev compose:** +- No `ports:` on postgres (internal network only — Pitfall 5 in RESEARCH) +- No minio / minio-init services (replaced by R2 env vars) +- Add `web` service: `image: ${IMAGE}:${TAG}`, `command: /app/web`, `env_file: .env.prod`, `depends_on: postgres: condition: service_healthy`, `expose: ["8080"]` +- Add `worker` service: same image, `command: /app/worker`, same env_file and depends_on +- Add `caddy` service: `image: caddy:2-alpine`, `ports: ["80:80","443:443","443:443/udp"]`, volumes for Caddyfile bind-mount + named caddy_data + caddy_config +- Volumes block: `postgres_data`, `caddy_data`, `caddy_config` (minio_data removed) +- Healthcheck interval: change to `interval: 10s`, `retries: 10` (slower startup tolerance in prod vs dev's 5s) + +--- + +### `backend/cmd/web/main.go` — add goose.Up() (modify, batch/startup) + +**Analog:** `backend/cmd/worker/main.go` lines 54–68 — rivermigrate startup pattern + +**rivermigrate pattern to mirror** (cmd/worker/main.go lines 54–68): +```go +// Step 2: Run river migrations before constructing the client (D-02). +// rivermigrate is idempotent — already-applied versions are skipped. +migrator, err := rivermigrate.New(riverpgxv5.New(pool), nil) +if err != nil { + slog.Error("rivermigrate init failed", "err", err) + os.Exit(1) +} +res, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil) +if err != nil { + slog.Error("river migration failed", "err", err) + os.Exit(1) +} +for _, v := range res.Versions { + slog.Info("river migration applied", "version", v.Version) +} +``` + +**goose.Up() equivalent to insert after pool creation** (after cmd/web/main.go line 64 `pool, err := db.NewPool`): +```go +// Step N: Run goose migrations at startup (D-10). db.RunMigrations is +// idempotent — already-applied versions are skipped via goose_db_version table. +if err := db.RunMigrations(ctx, pool); err != nil { + slog.Error("migrations failed", "err", err) + os.Exit(1) +} +``` + +**Ordering constraint:** Insert the goose.Up() call AFTER `pool, err := db.NewPool(ctx, dsn)` (line 64) and BEFORE `q := sqlc.New(pool)` (line 71). Matches the worker's pattern of running migrations before constructing any higher-level clients. + +**staticDir → fs.FS change** (cmd/web/main.go line 116): +```go +// BEFORE: +router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env) + +// AFTER (once assets package exists): +import "backend/assets" +router := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env) +``` + +--- + +### `backend/internal/db/migrate.go` (new, utility/batch) + +**Analog:** `backend/cmd/worker/main.go` — rivermigrate pattern (role-match: different library, same idiom of programmatic migration before server start) + +**Full file pattern** (new file, no existing analog — use RESEARCH Pattern 2 exactly): +```go +package db + +import ( + "context" + "database/sql" + + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" // registers "pgx/v5" driver with database/sql + "github.com/pressly/goose/v3" +) + +// RunMigrations opens a *sql.DB from the pool's connection string and runs all +// pending goose migrations embedded via the assets package. Idempotent: +// already-applied versions are skipped via the goose_db_version table. +func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error { + dsn := pool.Config().ConnConfig.ConnString() + db, err := sql.Open("pgx/v5", dsn) + if err != nil { + return err + } + defer db.Close() + + goose.SetBaseFS(migrationsFS) + if err := goose.SetDialect("postgres"); err != nil { + return err + } + return goose.Up(db, "migrations") +} +``` + +**Embed path note (RESEARCH Open Question 1):** The `//go:embed migrations` directive cannot live in `backend/internal/db/migrate.go` because `backend/migrations/` is 2 levels up and `go:embed` cannot use `..`. The embed directive MUST live in `backend/assets/assets.go` (or `cmd/web/main.go`). Pass the `embed.FS` into `RunMigrations` as a parameter, or expose it via the assets package. + +**Preferred signature** (accepts embed.FS as parameter so the function itself does not carry an embed directive): +```go +func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error +``` + +Called from `cmd/web/main.go` as: +```go +db.RunMigrations(ctx, pool, assets.Migrations) +``` + +--- + +### `backend/assets/assets.go` (new, utility/embed) + +**Analog:** None in this codebase — first `go:embed` usage. Pattern sourced from RESEARCH Pattern 1. + +**Full file pattern:** +```go +// Package assets embeds the static web assets and SQL migration files into the +// binary at build time (D-09, D-10). No runtime file dependencies. +package assets + +import "embed" + +// Static holds the compiled CSS, HTMX JS, and Sortable.js served at /static/*. +// The embed directive must be relative to this file; backend/static/ is a sibling +// of backend/assets/ at the module root level. +//go:embed static +var Static embed.FS + +// Migrations holds the goose SQL migration files embedded for programmatic +// execution at startup (D-10). +//go:embed migrations +var Migrations embed.FS +``` + +**Path verification required:** This file sits at `backend/assets/assets.go`. The `//go:embed static` path resolves to `backend/assets/static` — but `static/` is at `backend/static/`. The directory must either be moved inside `backend/assets/`, symlinked, or the embed directive must be placed in a file at `backend/` itself (e.g., `backend/embed.go`). See RESEARCH Pitfall 1 and Open Question 1. + +**Alternative (embed.go at module root):** +```go +// backend/embed.go +package main // or a dedicated package + +//go:embed static +var Static embed.FS + +//go:embed migrations +var Migrations embed.FS +``` + +This works because `backend/embed.go` is a sibling of `backend/static/` and `backend/migrations/`. The planner should prefer this approach over an `assets/` package if the path constraint cannot be resolved cleanly. + +--- + +### `backend/internal/web/handlers.go` (modify, handler/request-response) + +**Analog:** `backend/internal/web/handlers.go` itself — refactor existing HealthzHandler + +**Existing HealthzHandler pattern** (handlers.go lines 16–35) — this becomes ReadyzHandler: +```go +func HealthzHandler(pinger Pinger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + w.Header().Set("Content-Type", "application/json") + if err := pinger.Ping(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "degraded", + "db": "down", + }) + return + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "db": "ok", + }) + } +} +``` + +**Required changes:** +1. Rename existing `HealthzHandler(pinger Pinger)` → `ReadyzHandler(pinger Pinger)` (keep all logic identical) +2. Add new `HealthzHandler()` with no pinger dependency: +```go +// HealthzHandler is a pure liveness check (D-12). Returns 200 immediately +// as long as the server process is up. No DB ping. +func HealthzHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + } +} +``` + +**Imports:** Remove `"context"` and `"time"` from `HealthzHandler` imports (they move to `ReadyzHandler`). Both handlers remain in `handlers.go`. + +--- + +### `backend/internal/web/router.go` (modify, config/request-response) + +**Analog:** `backend/internal/web/router.go` itself + +**Existing static file serving pattern** (router.go line 120–121): +```go +fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))) +r.Get("/static/*", fs.ServeHTTP) +``` + +**Replacement pattern using embed.FS** (RESEARCH Pattern 1 / Code Examples): +```go +import "io/fs" + +// In NewRouter signature: change `staticDir string` → `staticFS fs.FS` +sub, err := fs.Sub(staticFS, "static") +if err != nil { + panic("static embed sub failed: " + err.Error()) +} +fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(sub))) +r.Get("/static/*", fileServer.ServeHTTP) +``` + +**Signature change** (router.go line 47): +```go +// BEFORE: +func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, ...) http.Handler + +// AFTER: +func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, ...) http.Handler +``` + +**Route changes** (router.go line 117): +```go +// BEFORE: +r.Get("/healthz", HealthzHandler(pinger)) + +// AFTER: +r.Get("/healthz", HealthzHandler()) // D-12: liveness, no pinger +r.Get("/readyz", ReadyzHandler(pinger)) // D-13: readiness, DB ping +``` + +**Test compatibility note:** All existing tests that call `NewRouter(stubPinger{}, "./static", ...)` must change their second argument. Tests can pass `os.DirFS("./static")` to avoid embedding during unit test runs. + +--- + +### `backend/internal/web/handlers_test.go` (modify, test/request-response) + +**Analog:** `backend/internal/web/handlers_test.go` itself + +**Existing tests to update** (handlers_test.go lines 24–61): + +`TestHealthz_OK` (lines 24–43) — change to test the new no-pinger liveness handler: +```go +func TestHealthz_OK(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + + // BEFORE: HealthzHandler(stubPinger{err: nil}) + // AFTER: HealthzHandler() — no pinger dependency + HealthzHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + // Remove db:ok assertion — new liveness handler only returns {"status":"ok"} + if !strings.Contains(rec.Body.String(), `"status":"ok"`) { + t.Errorf("body missing status:ok; got: %s", rec.Body.String()) + } +} +``` + +`TestHealthz_Down` (lines 45–61) — delete this test (the new HealthzHandler has no failure mode). + +**New tests to add** (mirror old TestHealthz structure, now for ReadyzHandler): +```go +func TestReadyz_OK(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) + + ReadyzHandler(stubPinger{err: nil}).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, `"status":"ok"`) { + t.Errorf("body missing status:ok; got: %s", body) + } + if !strings.Contains(body, `"db":"ok"`) { + t.Errorf("body missing db:ok; got: %s", body) + } +} + +func TestReadyz_Down(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) + + ReadyzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d; want 503", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, `"status":"degraded"`) { + t.Errorf("body missing status:degraded; got: %s", body) + } + if !strings.Contains(body, `"db":"down"`) { + t.Errorf("body missing db:down; got: %s", body) + } +} +``` + +**stubPinger reuse:** The existing `stubPinger` struct (handlers_test.go lines 18–22) is unchanged — it satisfies `Pinger` and is used by the new ReadyzHandler tests identically. + +**TestIndex_UnauthRedirects and other router-level tests** (handlers_test.go lines 68–121) — all pass `"./static"` as second arg to `NewRouter`. Change to `os.DirFS("./static")` after the signature changes. Requires adding `"os"` to imports. + +--- + +### `backend/Dockerfile` (new, config/batch) + +**Analog:** No Go Dockerfile in this codebase. Pattern sourced from RESEARCH Pattern 3. + +**Key decisions from justfile** (justfile lines 20–81 — pinned versions to match): +- `templ_version := "v0.3.1020"` — use this exact version in `go install github.com/a-h/templ/cmd/templ@v0.3.1020` +- `tailwind_version := "v4.0.0"` — Tailwind standalone CLI download +- `htmx_version := "2"` — unpkg.com HTMX download +- `sortable_version := "1.15.7"` — CDN Sortable.js download +- Builder base: `golang:1.26-alpine` (matches go.mod `go 1.26.1`) +- Runtime base: `gcr.io/distroless/static-debian12:nonroot` + +**Asset generation step** (mirrors justfile `bootstrap` + `generate` recipes): +```dockerfile +# Stage 1: Generate static assets (CSS + JS) +FROM node:20-alpine AS assets +WORKDIR /build +RUN apk add --no-cache curl +# Download Tailwind standalone CLI (pinned version from justfile) +RUN curl -sSL -o /usr/local/bin/tailwindcss \ + "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.0/tailwindcss-linux-x64" \ + && chmod +x /usr/local/bin/tailwindcss +# Download HTMX and Sortable.js (versions pinned in justfile) +RUN curl -sSL -o static/htmx.min.js "https://unpkg.com/htmx.org@2/dist/htmx.min.js" \ + && curl -sSL -o static/sortable.min.js \ + "https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js" +COPY tailwind.input.css . +COPY templates/ templates/ +RUN tailwindcss -i tailwind.input.css -o static/tailwind.css --minify +``` + +**Go build stage** (CGO_ENABLED=0 is mandatory for distroless/static — RESEARCH Pitfall 7): +```dockerfile +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=assets /build/static ./static + +# templ generate BEFORE go build — *_templ.go files are gitignored (RESEARCH Pitfall 6) +RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate + +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 +``` + +**Runtime stage** (D-08: no CMD — compose overrides with `command: /app/web` or `/app/worker`): +```dockerfile +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=builder /app/web /app/web +COPY --from=builder /app/worker /app/worker +EXPOSE 8080 +# No CMD — docker-compose.prod.yaml sets `command:` per service (D-08) +``` + +--- + +### `backend/deploy/Caddyfile` (new, config/request-response) + +**Analog:** None in codebase. + +**Pattern** (RESEARCH Pattern 6 — simple single-site reverse proxy): +```caddyfile +{$DOMAIN} { + reverse_proxy web:8080 +} +``` + +`{$DOMAIN}` is Caddy's env var interpolation syntax. The host `.env.prod` must set `DOMAIN=app.yourdomain.com`. Caddy automatically handles HTTP→HTTPS redirect and Let's Encrypt certificate issuance when a domain (not IP) is specified. + +**Persistent volume requirement:** The caddy service in docker-compose.prod.yaml MUST mount named volumes at `/data` and `/config` — without them, certificates are re-issued on every restart and hit Let's Encrypt rate limits (RESEARCH Pitfall 4). + +--- + +### `backend/.env.example` (modify, config) + +**Analog:** `backend/.env.example` itself + +**Existing vars** (current .env.example): +```bash +DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable +TEST_DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable +SESSION_SECRET= +PORT=8080 +ENV=development +``` + +**Add these vars** (R2 / production vars per D-05, D-06): +```bash +# S3-compatible object storage (Cloudflare R2 in prod; MinIO in local dev via compose.yaml) +S3_ENDPOINT=http://localhost:9000 +S3_BUCKET=xtablo-dev +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_USE_PATH_STYLE=true # true for MinIO; false for R2 + +# Maximum upload size in megabytes (default: 25) +MAX_UPLOAD_SIZE_MB=25 + +# Production domain for Caddy TLS (only needed in docker-compose.prod.yaml context) +# DOMAIN=app.yourdomain.com +``` + +**Note on TEST_DATABASE_URL:** The comment in CONTEXT.md and RESEARCH.md says to remove the `TEST_DATABASE_URL` note. Keep the var but update the comment to clarify it's dev/test-only and should not be set in the prod `.env.prod` file. + +--- + +### `backend/README.md` (modify, documentation) + +**Analog:** `backend/README.md` itself (lines 1–240 exist; add sections after line 240) + +**Sections to add** (append to existing README after the "What Phase 1 ships" section): + +The existing README structure uses H2 headers (`##`) for top-level sections. New sections follow the same pattern: + +```markdown +## Deploy + +Production runs on a Hetzner VM via plain Docker Compose. No PaaS, no Kubernetes (D-01, D-02). + +### First-time setup +[SSH, docker install, image build, .env.prod creation, docker compose up steps] + +### Deploying a new version +[docker build, tag, compose pull/up steps] + +## Rollback + +Normal rollback = change image tag in .env.prod + `docker compose up -d` (D-11). +[exact commands] + +## Incident runbook + +### /readyz returns 503 +### Database unreachable +### Caddy TLS certificate errors +[break-glass steps including goose down documentation] +``` + +--- + +## Shared Patterns + +### slog structured logging +**Source:** `backend/cmd/web/main.go` lines 41–41 and `backend/internal/web/slog.go` +**Apply to:** All new Go files that log at startup or on error +```go +slog.SetDefault(slog.New(web.NewSlogHandler(env, os.Stdout))) +// Then use slog.Error / slog.Info / slog.Warn directly +slog.Error("migrations failed", "err", err) +os.Exit(1) +``` + +### os.Exit(1) on startup failure +**Source:** `backend/cmd/web/main.go` lines 43–47, `backend/cmd/worker/main.go` lines 33–36 +**Apply to:** `db.RunMigrations` call in `cmd/web/main.go` +```go +if err := someStartupStep(...); err != nil { + slog.Error("step failed", "err", err) + os.Exit(1) +} +``` + +### httptest handler testing +**Source:** `backend/internal/web/handlers_test.go` lines 24–43 +**Apply to:** TestReadyz_OK, TestReadyz_Down, updated TestHealthz_OK +```go +rec := httptest.NewRecorder() +req := httptest.NewRequest(http.MethodGet, "/route", nil) +MyHandler(args...).ServeHTTP(rec, req) +if rec.Code != http.StatusOK { t.Fatalf(...) } +``` + +### pgxpool DSN extraction for sql.DB bridge +**Source:** RESEARCH Pattern 2 (no existing codebase usage — first occurrence) +**Apply to:** `backend/internal/db/migrate.go` +```go +dsn := pool.Config().ConnConfig.ConnString() +db, err := sql.Open("pgx/v5", dsn) +// Import _ "github.com/jackc/pgx/v5/stdlib" to register the driver +``` + +--- + +## No Analog Found + +Files with no close match in the codebase (planner should use RESEARCH.md patterns instead): + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `backend/deploy/Caddyfile` | config | request-response | No reverse proxy config exists in codebase; use RESEARCH Pattern 6 exactly | +| `backend/assets/assets.go` | utility | embed | No `go:embed` usage exists anywhere in this codebase; use RESEARCH Pattern 1; verify embed path resolution before committing | +| `backend/Dockerfile` | config | batch | No Go Dockerfile in this repo; TS `apps/api/Dockerfile` exists but is Node-based and not a useful pattern source; use RESEARCH Pattern 3 | + +--- + +## Critical Implementation Warnings + +1. **go:embed path constraint (RESEARCH Pitfall 1):** The `//go:embed` path must be relative to the .go file and cannot use `..`. Since `static/` and `migrations/` are at `backend/`, the embed directive file must also be at `backend/` — either as `backend/embed.go` (a file in `package main` or a new package) or inside `backend/assets/` if the directories are moved there. Verify before implementing. + +2. **templ generate in Docker (RESEARCH Pitfall 6):** `*_templ.go` files are gitignored. The Dockerfile builder stage MUST run `templ generate` at the pinned version (`v0.3.1020` from justfile) before `go build`, or the build will fail with undefined template functions. + +3. **goose needs *sql.DB not *pgxpool.Pool (RESEARCH Pitfall 2):** Must import `_ "github.com/jackc/pgx/v5/stdlib"` and bridge via `sql.Open("pgx/v5", connStr)`. Close the `*sql.DB` with `defer db.Close()` after migrations — the pool remains open for app use. + +4. **Postgres ports in prod compose:** Do NOT add `ports:` to the postgres service in `docker-compose.prod.yaml`. The postgres service is internal-only. The dev `compose.yaml` exposes `5432:5432` for local tooling — this must not be replicated in prod. + +5. **caddy_data volume:** Must be a named volume in `docker-compose.prod.yaml`. Without it, Caddy re-issues certificates on every restart and will hit Let's Encrypt rate limits (RESEARCH Pitfall 4). + +6. **web service depends_on with health condition:** `depends_on: postgres: condition: service_healthy` is required so `goose.Up()` does not race Postgres startup. The dev `compose.yaml` postgres service already has a `healthcheck:` — mirror it in the prod compose. + +## Metadata + +**Analog search scope:** `backend/cmd/`, `backend/internal/`, `backend/compose.yaml`, `backend/.env.example`, `backend/README.md`, `backend/justfile` +**Files scanned:** 14 +**Pattern extraction date:** 2026-05-15 diff --git a/.planning/phases/07-deploy-v1/07-RESEARCH.md b/.planning/phases/07-deploy-v1/07-RESEARCH.md index c2c7e2a..b2e7c24 100644 --- a/.planning/phases/07-deploy-v1/07-RESEARCH.md +++ b/.planning/phases/07-deploy-v1/07-RESEARCH.md @@ -546,22 +546,17 @@ func RunMigrations(pool *pgxpool.Pool, migrationsFS embed.FS) error { | A3 | Tailwind standalone binary download in Docker builder is reliable during CI/CD | Pattern 3 (Dockerfile) | If the external download is flaky, bake the Tailwind binary into the builder image or add it to the repo as a committed artifact | | A4 | `pool.Config().ConnConfig.ConnString()` reconstructs a DSN compatible with `sql.Open("pgx/v5", ...)` | Pattern 2 (goose bridge) | If ConnString() omits sslmode or other params, pass the original DATABASE_URL env var directly to sql.Open instead | -## Open Questions +## Open Questions (RESOLVED) -1. **embed.FS path for migrations/ in internal/db/migrate.go** - - What we know: `//go:embed` paths are relative to the Go source file and cannot use `..` to go above the module root. - - What's unclear: Whether `backend/internal/db/migrate.go` can embed `../../migrations/*.sql` — the `migrations/` directory is at `backend/migrations/`, and `internal/db/` is 2 levels deep. - - Recommendation: Test during Wave 1 implementation. If `../../migrations` is rejected, move the embed directive to an `assets` package at `backend/assets/` or to `cmd/web/main.go` itself and pass the `embed.FS` into `RunMigrations`. +1. **embed.FS path for migrations/ — RESOLVED: use `backend/embed.go` at module root** + - Resolution: No `.go` files exist in `backend/` root (verified: `ls backend/*.go` → no matches). Module is `module backend` (verified: `backend/go.mod` line 1). Create `backend/embed.go` with `package assets` — the embed directives `//go:embed all:static` and `//go:embed migrations` resolve correctly because `static/` and `migrations/` are siblings of the file. Import in `cmd/web/main.go` as `import assets "backend"`. + - The `../../migrations` path from `internal/db/migrate.go` is invalid (go:embed cannot use `..`). The `assets` package approach is the correct solution. -2. **templ generate in Docker build** - - What we know: `*_templ.go` files are gitignored (STATE.md); the build fails without them. - - What's unclear: Whether `RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate` in the builder stage is fast enough or needs caching. - - Recommendation: Use `--mount=type=cache,target=/root/.cache/go-build` on the `go build` steps; the templ generate step is fast (it's pure Go → Go codegen, no compilation). +2. **templ generate in Docker build — RESOLVED: add to builder stage with go build cache** + - Resolution: `RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate` runs in the builder stage before `go build`. Use `--mount=type=cache,target=/root/.cache/go-build` on `go build` steps to keep the build fast. templ generate is pure Go codegen — fast and deterministic. -3. **go:embed and files starting with `.` or `_`** - - What we know: By default, `//go:embed` excludes files/dirs starting with `.` or `_`. - - What's unclear: Whether `static/` contains any such files (e.g., `.gitkeep`). - - Recommendation: Check `ls -la backend/static/` during implementation. If such files exist, use `//go:embed all:static`. +3. **go:embed and `.gitkeep` in static/ — RESOLVED: use `//go:embed all:static`** + - Resolution: `backend/static/` contains `.gitkeep` (verified: `ls -la backend/static/` confirms `.gitkeep` exists). The `all:` prefix is required so `.gitkeep` is included. Use `//go:embed all:static` for Static and `//go:embed migrations` for Migrations (migrations/ has no hidden files). ## Environment Availability