# 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*
