docs(07): capture phase context

This commit is contained in:
Arthur Belleville 2026-05-15 17:35:43 +02:00
parent eec691442a
commit e14fd36fdc
No known key found for this signature in database
2 changed files with 275 additions and 0 deletions

View file

@ -0,0 +1,119 @@
# Phase 7: Deploy v1 - Context
**Gathered:** 2026-05-15
**Status:** Ready for planning
<domain>
## Phase Boundary
Package the Go backend into a production-ready Docker image, deploy it to a Hetzner VM via docker compose, wire Caddy as a TLS-terminating reverse proxy, inject secrets from a host-side `.env` file, run goose migrations programmatically on startup, and document the deploy + rollback runbook in `backend/README.md`.
Delivers DEPLOY-01, DEPLOY-02, DEPLOY-03, DEPLOY-04, DEPLOY-05. **Not in scope:** CI/CD pipelines, container registry automation, Dokploy or any PaaS layer, multi-node deployment, backup tooling, monitoring beyond `/healthz` + `/readyz`.
</domain>
<decisions>
## Implementation Decisions
### Deploy Target
- **D-01:** Production host is a **Hetzner VM** running Docker Compose. No PaaS, no Kubernetes.
- **D-02:** The full stack runs via **plain `docker compose`** — no Dokploy or Swarm mode in v1. (User previously used Dokploy but will not in this phase; can be layered on later.)
- **D-03:** **Postgres runs on the VM** inside the compose stack, volume-backed. No managed Postgres service for v1.
- **D-04:** **Caddy** is a service in `docker-compose.prod.yaml`. It proxies to `web:8080` and handles TLS via Let's Encrypt. Config via a bind-mounted `Caddyfile`.
### Secret Management
- **D-05:** Production secrets (`SESSION_SECRET`, `DATABASE_URL`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ENDPOINT_URL`, `AWS_BUCKET`, `PORT`, `ENV`) are stored in a **`.env` file on the Hetzner host** (gitignored). `docker compose --env-file .env.prod up` reads it. No SOPS, no Docker secrets API.
- **D-06:** S3-compatible storage in production is **Cloudflare R2**. R2 credentials (account ID, access key, secret key, bucket name, endpoint URL) live in the host `.env` file. MinIO remains in `compose.yaml` for local dev only.
### Image Design
- **D-07:** A **single multi-stage Dockerfile** produces one image. The image contains **two binaries**: `/app/web` (from `cmd/web`) and `/app/worker` (from `cmd/worker`). Both are compiled in the builder stage and copied to the final `gcr.io/distroless/static` (or `alpine`) runtime stage.
- **D-08:** `docker-compose.prod.yaml` runs the same image twice: one service with `command: /app/web`, one with `command: /app/worker`. No subcommand dispatcher needed in Go code.
- **D-09:** All static assets (Tailwind-compiled CSS, HTMX JS, Sortable.js, templ-generated HTML templates) are **embedded via `//go:embed`** at build time. No volume mounts for assets. The container has zero runtime file dependencies beyond the binary.
### Migration Execution
- **D-10:** Migrations run **programmatically inside the `web` binary** at startup: `web` calls `goose.Up()` via the goose library before binding the HTTP server. Mirrors the `rivermigrate` pattern already in `cmd/worker`. No separate goose CLI binary in the image, no separate migrate service.
- **D-11:** **Rollback strategy**: redeploy the previous image tag. The runbook documents tagging each build (e.g., `:v0.1.0`, `:latest`). Rollback = update compose image tag + `docker compose up -d`. Destructive schema rollbacks via `goose down` are documented as a break-glass step only; they are not part of the normal rollback path.
### Health Checks
- **D-12:** `/healthz` — liveness: returns 200 OK immediately if the server is up (no DB ping). Used by Caddy / uptime monitor.
- **D-13:** `/readyz` — readiness: returns 200 OK only if the DB pool is reachable (one `db.Ping()` call). Returns 503 during startup until migrations complete and the pool is healthy. Worker does not expose HTTP; health is observable via structured logs only.
### Claude's Discretion
- Exact Dockerfile base image for the builder stage (e.g., `golang:1.26-alpine` vs `golang:1.26`).
- Final runtime base: `distroless/static` (smallest, no shell) vs `alpine` (has shell for debugging). Either is acceptable.
- Caddyfile content (reverse proxy config, TLS directive, HTTPS redirect).
- Whether `docker-compose.prod.yaml` includes a `healthcheck:` directive for the Postgres service (matching `compose.yaml`'s existing pattern).
- Exact docker compose version / syntax used (`compose.yaml` already uses v2 syntax).
- Whether the `web` service in prod compose depends_on the `postgres` service with a health condition.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Requirements
- `.planning/REQUIREMENTS.md` §Deploy (DEPLOY-01..05) — The 5 deploy requirements this phase delivers
- `.planning/PROJECT.md` — Core constraints: single VPS, no Supabase, S3-compatible storage (R2), single binary + background worker
- `.planning/ROADMAP.md` §Phase 7 — Success criteria and user-in-loop decisions
### Prior Phase Context
- `.planning/phases/06-background-worker/06-CONTEXT.md` — D-01..D-08 (river setup, worker binary structure, cmd/worker entrypoint pattern, rivermigrate programmatic migration approach)
- `.planning/phases/05-files/05-CONTEXT.md` — S3 client setup, MinIO in compose.yaml, file store interface (orphan-cleanup job and web handlers both use the same S3 client)
- `.planning/phases/01-foundation/01-CONTEXT.md` — cmd/web and cmd/worker entrypoints, goose migration conventions, justfile targets
### Codebase Entry Points
- `backend/cmd/web/main.go` — Web binary entrypoint; will call goose.Up() before serving
- `backend/worker/cmd/worker/main.go` — Worker binary entrypoint; already calls rivermigrate at startup (model for goose.Up() pattern)
- `backend/compose.yaml` — Dev compose with postgres + minio; docker-compose.prod.yaml will be a sibling file with postgres + web + worker + caddy
- `backend/migrations/` — goose migration files numbered 0001..000N; embedded via go:embed in web binary
- `backend/.env.example` — Canonical list of env vars; prod .env on the host must mirror this
- `backend/justfile` — Bootstrap and dev workflow; runbook can reference just targets for local validation
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `db.NewPool(ctx, dsn)` — already called in both cmd/web and cmd/worker; prod image uses the same function with DATABASE_URL from env
- `web.NewSlogHandler(env, os.Stdout)` — structured JSON logging in production (`ENV=production`); already switches on `ENV`
- `signal.NotifyContext` pattern — already in cmd/worker for graceful shutdown; cmd/web uses the same approach; both binaries already handle SIGINT/SIGTERM correctly
- `rivermigrate` startup call in cmd/worker — the pattern to follow for goose.Up() in cmd/web
### Established Patterns
- **go:embed** — templates package already embeds templ output. Same pattern applies for `static/` assets (CSS, JS).
- **ENV var for environment selector**`ENV=development` uses text slog, `ENV=production` uses JSON slog. Already wired; no changes needed.
- **`just` + bootstrap** — local dev uses justfile; the runbook can reference `just build` or `go build` steps for pre-push validation.
### Integration Points
- `backend/compose.yaml``docker-compose.prod.yaml`: prod file reuses the same postgres service definition + minio replaced by R2 credentials
- `backend/.env.example` → host `.env.prod`: same var names, real production values
- `backend/internal/db/` → goose.Up() at web startup: migrations directory path must be resolvable (via go:embed FS or an explicit path argument)
</code_context>
<specifics>
## Specific Ideas
- User wants **Caddy** as the reverse proxy — this is deliberate, not just a recommendation. Caddyfile should be part of the repo under `backend/deploy/Caddyfile` or similar, bind-mounted at runtime.
- User explicitly chose **plain docker compose** (not Dokploy, not Swarm) — runbook must be purely SSH + compose commands, no panel UI references.
- Worker health is **log-observable only** — no HTTP endpoint needed for the worker service.
</specifics>
<deferred>
## Deferred Ideas
- **Dokploy layer** — user mentioned prior Dokploy usage; deferred. Can be added on top of the docker compose structure without changing the image.
- **CI/CD pipeline** — automated image build + deploy on git push. Not in v1 scope; runbook documents manual steps.
- **pg_dump backup cron** — Postgres backup strategy. Noted as future ops work.
- **MinIO for prod** — user chose R2; MinIO-as-prod-storage is deferred if ever needed.
</deferred>
---
*Phase: 7-Deploy v1*
*Context gathered: 2026-05-15*

View file

@ -0,0 +1,156 @@
# Phase 7: Deploy v1 - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-05-15
**Phase:** 7-Deploy v1
**Areas discussed:** Deploy target, Secret management, Image design, Migration execution
---
## Deploy Target
| Option | Description | Selected |
|--------|-------------|----------|
| Fly.io | Container PaaS with first-class Docker support, fly secrets, fly.toml | |
| Hetzner VM | Raw VPS ~€5/mo, full Docker control, SSH, Caddy/nginx for TLS | ✓ |
| Cloud Run (GCP) | Serverless containers, familiar from JS API, but heavier setup | |
**User's choice:** Hetzner VM
---
| Option | Description | Selected |
|--------|-------------|----------|
| docker compose on the VM | docker-compose.prod.yaml, `docker compose pull && up -d` | ✓ |
| docker run directly | Two docker run invocations, no compose | |
**User's choice:** docker compose on the VM
**Notes:** User mentioned prior Dokploy usage but explicitly chose plain docker compose for this phase. Dokploy can be layered on later.
---
| Option | Description | Selected |
|--------|-------------|----------|
| Design for Dokploy | Standard image + compose, Dokploy triggers, dual runbook | |
| Plain docker compose only | SSH + docker compose only, no panel | ✓ |
**User's choice:** No — plain docker compose only
---
| Option | Description | Selected |
|--------|-------------|----------|
| Postgres on the VM in docker compose | Same compose stack, volume-backed, pg_dump for backups | ✓ |
| Managed Postgres (external) | Neon / Supabase Postgres / Hetzner Managed DB | |
**User's choice:** On the VM in docker compose
---
| Option | Description | Selected |
|--------|-------------|----------|
| Caddy as a service in docker-compose.prod.yaml | caddy service, Caddyfile bind-mounted, auto-HTTPS | ✓ |
| Caddy installed on the host VM (outside Docker) | systemd service on VM, proxies localhost:8080 | |
**User's choice:** Caddy as a service in docker-compose.prod.yaml
**Notes:** User explicitly requested Caddy — mentioned as an addition to the compose stack.
---
## Secret Management
| Option | Description | Selected |
|--------|-------------|----------|
| .env file on the VM | Host-side file, gitignored, docker compose --env-file reads it | ✓ |
| SOPS-encrypted secrets | Encrypted with SOPS + age, decrypted at deploy | |
| Docker secrets (Swarm-style) | Secrets API, requires Swarm mode | |
**User's choice:** .env file on the VM
---
| Option | Description | Selected |
|--------|-------------|----------|
| Cloudflare R2 | PROJECT.md target, zero egress fees, S3-compatible | ✓ |
| MinIO on the same VM | Already in compose.yaml for dev, simpler but owns storage ops | |
**User's choice:** Cloudflare R2
---
## Image Design
| Option | Description | Selected |
|--------|-------------|----------|
| One image, two docker-compose services | Single multi-stage build, compose runs it twice | ✓ |
| Two separate Dockerfiles / images | Independent image tags, doubles CI complexity | |
**User's choice:** One image, two docker-compose services
---
| Option | Description | Selected |
|--------|-------------|----------|
| Separate binaries in one image | /app/web and /app/worker, compose sets command | ✓ |
| Single binary with subcommand | cmd/xtablo dispatches on os.Args[0] or subcommand | |
**User's choice:** Separate binaries in one image
---
| Option | Description | Selected |
|--------|-------------|----------|
| Embedded via go:embed | All static files baked in at build time | ✓ |
| Mounted volume / copied into image | Assets COPYed into image, no embed | |
**User's choice:** Embedded via go:embed
---
## Migration Execution
| Option | Description | Selected |
|--------|-------------|----------|
| Entrypoint script in the container | goose.Up() before binary serves, automatic on docker compose up | ✓ |
| Separate migrate service in docker-compose.prod.yaml | One-shot service + depends_on --wait | |
| Manual step in the runbook only | SSH + docker compose run, fully explicit | |
**User's choice:** Entrypoint script (goose.Up() programmatic)
---
| Option | Description | Selected |
|--------|-------------|----------|
| Go-programmatic migration in binary | web calls goose.Up(), mirrors rivermigrate pattern | ✓ |
| goose CLI binary in image | Multi-stage installs goose CLI, entrypoint calls it | |
**User's choice:** Go-programmatic migration in the binary
---
| Option | Description | Selected |
|--------|-------------|----------|
| Redeploy previous image tag | Keep :v0.1.0 tag, rollback = change tag + compose up | ✓ |
| goose down in runbook | Explicit schema rollback step | |
| Claude decides | Planner figures out rollback story | |
**User's choice:** Document in runbook: redeploy previous image tag
---
## Claude's Discretion
- Dockerfile base image for builder stage (golang:1.26-alpine vs golang:1.26)
- Final runtime base (distroless/static vs alpine)
- Caddyfile content
- Whether docker-compose.prod.yaml includes healthcheck for postgres service
- Whether web service depends_on postgres with health condition
## Deferred Ideas
- Dokploy layer — user has prior experience; deferred, can layer on later
- CI/CD pipeline — automated build + deploy on git push; runbook covers manual for v1
- pg_dump backup cron — future ops work
- MinIO for prod — user chose R2; MinIO-as-prod deferred