docs(07): capture phase context
This commit is contained in:
parent
eec691442a
commit
e14fd36fdc
2 changed files with 275 additions and 0 deletions
119
.planning/phases/07-deploy-v1/07-CONTEXT.md
Normal file
119
.planning/phases/07-deploy-v1/07-CONTEXT.md
Normal 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*
|
||||
156
.planning/phases/07-deploy-v1/07-DISCUSSION-LOG.md
Normal file
156
.planning/phases/07-deploy-v1/07-DISCUSSION-LOG.md
Normal 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
|
||||
Loading…
Reference in a new issue