diff --git a/.planning/phases/07-deploy-v1/07-02-SUMMARY.md b/.planning/phases/07-deploy-v1/07-02-SUMMARY.md new file mode 100644 index 0000000..5daa7fd --- /dev/null +++ b/.planning/phases/07-deploy-v1/07-02-SUMMARY.md @@ -0,0 +1,104 @@ +--- +phase: 07-deploy-v1 +plan: "02" +subsystem: backend-deploy +tags: [go, docker, dockerfile, multi-stage, distroless, s3, env] +dependency_graph: + requires: + - 07-01 (embed.FS anchor + RunMigrations — both binaries reference backend package) + provides: + - backend/Dockerfile — 3-stage multi-stage Docker build producing /app/web and /app/worker (DEPLOY-01) + - backend/.env.example — complete operator reference for S3/R2 and production vars (DEPLOY-02) + affects: + - backend/Dockerfile (new file) + - backend/.env.example (updated) +tech_stack: + added: + - gcr.io/distroless/static-debian12:nonroot — minimal runtime base image (D-07) + - node:20-alpine — asset compilation stage (Tailwind standalone CLI v4.0.0) + - golang:1.26-alpine — Go compilation stage + patterns: + - Multi-stage Docker build: assets → builder → runtime + - No CMD in final stage — docker-compose overrides command: per service (D-08) + - CGO_ENABLED=0 static binaries for distroless compatibility (D-07) + - templ generate at pinned v0.3.1020 before go build (gitignored *_templ.go files) + - go build cache mount (--mount=type=cache,target=/root/.cache/go-build) for layer caching +key_files: + created: + - backend/Dockerfile + modified: + - backend/.env.example +decisions: + - "node:20-alpine for assets stage — matches Node version used by JS monorepo apps/api/Dockerfile" + - "Tailwind downloaded as standalone binary (not npm package) — matches justfile bootstrap approach" + - "Both go build commands use separate RUN --mount=type=cache,target=/root/.cache/go-build — enables independent layer caching per binary" + - "DOMAIN var commented out in .env.example — it is only needed in production .env.prod and should not have a misleading default value" + - "S3_USE_PATH_STYLE=true as dev default — MinIO requires path-style; prod R2 operator must flip to false" +metrics: + duration: "~4 minutes" + completed: "2026-05-15" + tasks: 2 + files: 2 +--- + +# Phase 7 Plan 2: Multi-stage Dockerfile and .env.example S3/R2 vars Summary + +## What Was Built + +3-stage multi-stage Dockerfile producing two CGO_ENABLED=0 static binaries (/app/web and /app/worker) in a distroless nonroot runtime image, plus .env.example updated with S3/R2 vars, MAX_UPLOAD_SIZE_MB, and a commented DOMAIN entry for production operators. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | Multi-stage Dockerfile for web + worker binaries | f29bf0c | backend/Dockerfile | +| 2 | Update .env.example with S3/R2, DOMAIN, MAX_UPLOAD_SIZE_MB vars | 0781403 | backend/.env.example | + +## Decisions Made + +1. The assets stage uses `node:20-alpine` (not pure alpine + curl) to avoid custom tool installation — the Tailwind standalone CLI is a binary download anyway, so any alpine works; node:20 matches the JS monorepo API Dockerfile for consistency. + +2. Both `go build` RUN instructions use separate `--mount=type=cache` directives so Docker can cache each binary's compilation independently. If only `cmd/web` changes, the `cmd/worker` layer reuses the cache. + +3. `DOMAIN` is commented out in `.env.example` rather than given a placeholder value. A live domain value with no comment context could mislead operators into thinking the empty string is a valid default; the comment makes the production-only context explicit. + +4. `S3_USE_PATH_STYLE=true` is the dev default (MinIO requires path-style URLs). The comment explicitly instructs production operators to set this to `false` for Cloudflare R2 virtual-hosted-style URLs. + +5. `TEST_DATABASE_URL` comment updated to flag it as dev/test only — the .env.example serves as the reference for `.env.prod` and the clarification prevents accidentally including test connection strings in production. + +## Deviations from Plan + +None — plan executed exactly as written. + +## Verification + +Dockerfile structure verified by inspecting key markers: +- 3 named stages: `assets`, `builder`, runtime (from `gcr.io/distroless/static-debian12:nonroot`) +- `templ generate` at `v0.3.1020` +- `CGO_ENABLED=0 GOOS=linux` on both go build commands +- No `CMD` instruction in final stage +- No `COPY .env*` anywhere (T-07-05 mitigated) + +.env.example verified: +``` +grep count for S3_ENDPOINT|S3_BUCKET|S3_ACCESS_KEY|S3_SECRET_KEY|S3_REGION|S3_USE_PATH_STYLE|MAX_UPLOAD_SIZE_MB|DOMAIN = 8 +``` +All 8 new vars present. All original vars (DATABASE_URL, TEST_DATABASE_URL, SESSION_SECRET, PORT, ENV) preserved. + +## Known Stubs + +None. + +## Threat Flags + +No new threat surface beyond plan's threat_model. + +- T-07-05 mitigated: No `.env*` files are COPY'd in any Dockerfile stage. Secrets reach the runtime only via `env_file:` in docker-compose at runtime. +- T-07-07 mitigated: `:nonroot` tag confirmed in final FROM line — runs as uid 65532. + +## Self-Check: PASSED + +- backend/Dockerfile: EXISTS +- backend/.env.example: EXISTS (contains S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB, DOMAIN) +- Commit f29bf0c: VERIFIED in git log +- Commit 0781403: VERIFIED in git log diff --git a/backend/.env.example b/backend/.env.example index f55075d..c472a67 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,7 @@ DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable # Postgres connection string used by integration tests (auth, session, etc.). # Falls back to DATABASE_URL if unset; tests skip if neither is set. # The test harness creates an isolated schema per test run and drops it on cleanup. +# DEV/TEST ONLY — do NOT include TEST_DATABASE_URL in .env.prod (not used at runtime). TEST_DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable # Session secret — 32 random bytes hex-encoded. Used as the CSRF authentication key. @@ -16,3 +17,39 @@ PORT=8080 # Environment selector: "development" enables the slog text handler; "production" switches to JSON. ENV=development + +# --------------------------------------------------------------------------- +# S3-compatible object storage (Cloudflare R2 in production, MinIO in dev) +# --------------------------------------------------------------------------- + +# S3 endpoint URL. +# Dev (MinIO): http://localhost:9000 +# Production (R2): https://.r2.cloudflarestorage.com (D-06) +S3_ENDPOINT=http://localhost:9000 + +# S3 bucket name. +S3_BUCKET=xtablo-dev + +# S3 region. Cloudflare R2 accepts "auto" or a standard region token; "us-east-1" is safe default. +S3_REGION=us-east-1 + +# S3 access key (MinIO dev default: minioadmin). +S3_ACCESS_KEY=minioadmin + +# S3 secret key (MinIO dev default: minioadmin). +S3_SECRET_KEY=minioadmin + +# Use path-style S3 URLs. +# true — for MinIO and other self-hosted S3 (path-style: http://host/bucket/key). +# false — for Cloudflare R2 (virtual-hosted-style: https://bucket.host/key). +S3_USE_PATH_STYLE=true + +# Maximum file upload size in megabytes. Default 25 if unset. +MAX_UPLOAD_SIZE_MB=25 + +# --------------------------------------------------------------------------- +# Production domain (used in docker-compose.prod.yaml for Caddy TLS — D-04) +# --------------------------------------------------------------------------- + +# Uncomment and set to your domain in .env.prod. +# DOMAIN=app.yourdomain.com diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a55e348 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,93 @@ +# Multi-stage Dockerfile for the Xtablo Go backend. +# +# Three stages: +# assets — downloads Tailwind CLI, HTMX, and Sortable.js then compiles the +# Tailwind output CSS from tailwind.input.css + templates/. (D-09) +# builder — copies assets, runs `templ generate`, and compiles both binaries +# with CGO_ENABLED=0 so they are fully static. (D-07) +# runtime — copies only the two binaries into a minimal distroless image. +# No CMD is set — docker-compose overrides command: per service. (D-08) +# +# Build context: backend/ directory (sibling to go.mod). +# From the repo root: docker build -f backend/Dockerfile backend/ +# From inside backend/: docker build . +# +# Security: no .env files are ever COPY'd into any layer (T-07-05). +# The :nonroot distroless tag runs as uid 65532, preventing filesystem writes (T-07-07). + +# --------------------------------------------------------------------------- +# Stage 1: assets +# Downloads pinned versions of Tailwind CLI, HTMX, and Sortable.js, +# then compiles tailwind.input.css against templates/ for the minified CSS. +# --------------------------------------------------------------------------- +FROM node:20-alpine AS assets + +WORKDIR /build + +RUN apk add --no-cache curl + +RUN mkdir -p static + +# Tailwind standalone CLI — pinned at v4.0.0 (matches justfile tailwind_version) +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 + +# HTMX — pinned at major version 2 (matches justfile htmx_version) +RUN curl -sSL -o static/htmx.min.js \ + "https://unpkg.com/htmx.org@2/dist/htmx.min.js" + +# Sortable.js — pinned at v1.15.7 (matches justfile sortable_version) +RUN curl -sSL -o static/sortable.min.js \ + "https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js" + +# Copy Tailwind input and templates for content-scanning +COPY tailwind.input.css . +COPY templates/ templates/ + +# Compile and minify Tailwind CSS +RUN tailwindcss -i tailwind.input.css -o static/tailwind.css --minify + +# --------------------------------------------------------------------------- +# Stage 2: builder +# Compiles both Go binaries (cmd/web and cmd/worker) with CGO_ENABLED=0. +# Runs `templ generate` first since *_templ.go files are gitignored. (D-07) +# --------------------------------------------------------------------------- +FROM golang:1.26-alpine AS builder + +WORKDIR /app + +# Download dependencies first (layer-cached until go.mod/go.sum change) +COPY go.mod go.sum ./ +RUN go mod download + +# Copy the entire backend context +COPY . . + +# Overwrite static/ with the freshly built assets from the assets stage +COPY --from=assets /build/static ./static + +# Install templ at the pinned version and run template generation +# (gitignored *_templ.go files must be generated at build time) +RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate + +# Compile the web server binary — CGO_ENABLED=0 required for distroless/static +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 + +# Compile the background worker binary — CGO_ENABLED=0 required for distroless/static +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 + +# --------------------------------------------------------------------------- +# Stage 3: runtime +# Minimal distroless image containing only the two compiled binaries. +# Runs as nonroot (uid 65532) — no filesystem write access. (T-07-07) +# No CMD set — docker-compose.prod.yaml provides `command:` per service. (D-08) +# --------------------------------------------------------------------------- +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /app/web /app/web +COPY --from=builder /app/worker /app/worker + +EXPOSE 8080