chore: merge executor worktree (worktree-agent-ad2dff45f7520558c)

This commit is contained in:
Arthur Belleville 2026-05-15 18:20:51 +02:00
commit 45701bf8aa
No known key found for this signature in database
3 changed files with 234 additions and 0 deletions

View file

@ -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

View file

@ -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://<account-id>.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

93
backend/Dockerfile Normal file
View file

@ -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