diff --git a/.planning/phases/07-deploy-v1/07-CONTEXT.md b/.planning/phases/07-deploy-v1/07-CONTEXT.md new file mode 100644 index 0000000..77a8ac6 --- /dev/null +++ b/.planning/phases/07-deploy-v1/07-CONTEXT.md @@ -0,0 +1,119 @@ +# Phase 7: Deploy v1 - Context + +**Gathered:** 2026-05-15 +**Status:** Ready for planning + + +## 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`. + + + + +## 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. + + + + +## 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 + + + + +## 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) + + + + +## 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. + + + + +## 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. + + + +--- + +*Phase: 7-Deploy v1* +*Context gathered: 2026-05-15* diff --git a/.planning/phases/07-deploy-v1/07-DISCUSSION-LOG.md b/.planning/phases/07-deploy-v1/07-DISCUSSION-LOG.md new file mode 100644 index 0000000..1504a58 --- /dev/null +++ b/.planning/phases/07-deploy-v1/07-DISCUSSION-LOG.md @@ -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