---
phase: 07-deploy-v1
plan: 02
type: execute
wave: 2
depends_on:
  - 07-01
files_modified:
  - backend/Dockerfile
  - backend/.env.example
autonomous: true
requirements:
  - DEPLOY-01
  - DEPLOY-02

must_haves:
  truths:
    - "D-07: A single Dockerfile builds both /app/web and /app/worker binaries in one image"
    - "The builder stage runs templ generate before go build (gitignored *_templ.go files)"
    - "The final runtime image is distroless/static-debian12:nonroot with CGO_ENABLED=0 binaries"
    - "D-08: The image has no CMD — docker-compose overrides command: per service"
    - "D-05/D-06: .env.example documents all production env vars including S3/R2, DOMAIN, and MAX_UPLOAD_SIZE_MB"
  artifacts:
    - path: "backend/Dockerfile"
      provides: "3-stage multi-stage Docker build: assets, builder, runtime"
      contains: "distroless/static-debian12"
    - path: "backend/.env.example"
      provides: "documented env vars for both dev and production including S3_ENDPOINT, S3_BUCKET, DOMAIN"
      contains: "S3_ENDPOINT"
  key_links:
    - from: "Dockerfile builder stage"
      to: "templ generate"
      via: "RUN go install templ@v0.3.1020 && templ generate"
      pattern: "templ generate"
    - from: "Dockerfile builder stage"
      to: "/app/web and /app/worker binaries"
      via: "CGO_ENABLED=0 go build -o /app/web ./cmd/web && go build -o /app/worker ./cmd/worker"
      pattern: "CGO_ENABLED=0"
---

<objective>
Deliver the multi-stage Dockerfile (D-07, DEPLOY-01) and the updated `.env.example` (D-05, DEPLOY-02):

1. Create `backend/Dockerfile` with three stages: (a) `assets` stage downloads/builds Tailwind CSS + HTMX JS + Sortable.js; (b) `builder` stage copies assets, runs `templ generate`, and compiles both binaries with CGO_ENABLED=0; (c) `runtime` stage copies only the two binaries into a distroless base.
2. Update `backend/.env.example` to add S3/R2 vars, MAX_UPLOAD_SIZE_MB, and a DOMAIN comment — these vars are referenced in docker-compose.prod.yaml and the runbook.

Purpose: The Dockerfile is the deliverable for DEPLOY-01. Without it, there is no production image. The .env.example update ensures operators have a complete reference for what `.env.prod` must contain on the Hetzner VM.

Output: `backend/Dockerfile` and updated `backend/.env.example` committed. The Docker builder stage can be verified by running `docker build --target builder`.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-PATTERNS.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-01-SUMMARY.md
</context>

<interfaces>
<!-- Key decisions and pinned versions from codebase. -->

From backend/justfile (pinned tool versions — must match exactly in Dockerfile):
- templ_version := "v0.3.1020"
- tailwind_version := "v4.0.0"  → Tailwind standalone CLI URL: https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.0/tailwindcss-linux-x64
- htmx_version := "2"           → HTMX URL: https://unpkg.com/htmx.org@2/dist/htmx.min.js
- sortable_version := "1.15.7"  → Sortable URL: https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js
- Go version: 1.26 (from go.mod `go 1.26.1`) → builder base: golang:1.26-alpine

From RESEARCH.md (locked decisions):
- D-07: single Dockerfile, two binaries /app/web and /app/worker
- D-08: no CMD in Dockerfile — compose overrides with command: per service
- D-09: all assets embedded; no volume mounts
- Runtime base: gcr.io/distroless/static-debian12:nonroot

From backend/.env.example (current contents — vars to preserve and add to):
- DATABASE_URL, TEST_DATABASE_URL, SESSION_SECRET, PORT=8080, ENV=development
- Missing (to add): S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB, DOMAIN

From RESEARCH Anti-Patterns:
- CGO_ENABLED=0 is mandatory (distroless/static has no C libs)
- No CMD in final stage (compose overrides)
- Use go build cache: --mount=type=cache,target=/root/.cache/go-build
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Multi-stage Dockerfile for web + worker binaries</name>
  <files>
    backend/Dockerfile
  </files>
  <read_first>
    - backend/justfile (lines 1-40 — pinned versions for tailwind, htmx, sortable, templ; also check if tailwind reads from templates/ directory for content scanning)
    - backend/tailwind.input.css (exists at backend/tailwind.input.css — verify path for COPY in assets stage)
    - backend/templates/ (directory exists — needed by Tailwind for content scanning in assets stage)
    - backend/go.mod (first line — confirms module name "backend" and go version "1.26.1")
    - backend/cmd/web/ (verify main.go is the entry point for /app/web binary)
    - backend/cmd/worker/ (verify main.go is the entry point for /app/worker binary)
    - backend/static/ (ls -la — check for files starting with . or _ that would be excluded by go:embed without "all:" prefix)
  </read_first>
  <action>
    Create `backend/Dockerfile` at the `backend/` directory root (sibling to go.mod). The Dockerfile context root will be `backend/` (i.e., `docker build -f backend/Dockerfile backend/` from the repo root, or `docker build .` from inside `backend/`).

    Stage 1 — named `assets`, base `node:20-alpine`: WORKDIR /build. Run `apk add --no-cache curl`. Create `static/` dir with `mkdir -p static`. Download Tailwind standalone CLI to `/usr/local/bin/tailwindcss` from the pinned URL (v4.0.0 linux-x64) and `chmod +x`. Download HTMX: `curl -sSL -o static/htmx.min.js "https://unpkg.com/htmx.org@2/dist/htmx.min.js"`. Download Sortable.js: `curl -sSL -o static/sortable.min.js "https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js"`. COPY the Tailwind input and templates: `COPY tailwind.input.css .` and `COPY templates/ templates/`. Run Tailwind: `tailwindcss -i tailwind.input.css -o static/tailwind.css --minify`.

    Stage 2 — named `builder`, base `golang:1.26-alpine`: WORKDIR /app. COPY go.mod go.sum ./. RUN go mod download. COPY . . (copies entire backend/ context). COPY --from=assets /build/static ./static (overwrites static/ with freshly-built assets). Run templ generate at the pinned version: `RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate`. Then build both binaries with build cache mounts and CGO_ENABLED=0: `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`. `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, base `gcr.io/distroless/static-debian12:nonroot`: COPY --from=builder /app/web /app/web. COPY --from=builder /app/worker /app/worker. EXPOSE 8080. No CMD (per D-08 — docker-compose.prod.yaml sets `command:` per service). No ENTRYPOINT.

    Add a comment at the top of the Dockerfile explaining the three-stage structure and referencing D-07, D-08, D-09.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && docker build -f Dockerfile --target builder -t xtablo-builder-test . 2>&1 | tail -5</automated>
  </verify>
  <done>
    backend/Dockerfile exists with three stages (assets, builder, runtime). `docker build --target builder` succeeds (both web and worker binaries compiled). Final stage uses gcr.io/distroless/static-debian12:nonroot. No CMD instruction in the final stage. templ generate runs at v0.3.1020. CGO_ENABLED=0 on both go build commands.
  </done>
</task>

<task type="auto">
  <name>Task 2: Update .env.example with S3/R2, DOMAIN, and MAX_UPLOAD_SIZE_MB vars</name>
  <files>
    backend/.env.example
  </files>
  <read_first>
    - backend/.env.example (full file — current content to preserve before adding new vars)
    - backend/cmd/web/main.go (S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB — verify exact env var names as read by main.go)
  </read_first>
  <action>
    Append to `backend/.env.example` (after the existing ENV=development line) a new section with these vars and comments:

    S3_ENDPOINT — with example value `http://localhost:9000` (MinIO dev value) and a comment: "In production (Cloudflare R2): https://<account-id>.r2.cloudflarestorage.com (D-06)". S3_BUCKET with example `xtablo-dev`. S3_REGION with example `us-east-1` (R2 uses auto or a region token; keep us-east-1 as default). S3_ACCESS_KEY with example `minioadmin` (dev value). S3_SECRET_KEY with example `minioadmin` (dev value). S3_USE_PATH_STYLE with example `true` and comment: "true for MinIO (path-style); set to false for Cloudflare R2 (virtual-hosted)". MAX_UPLOAD_SIZE_MB with example `25` and comment: "Maximum upload size in megabytes. Default 25 if unset." DOMAIN — commented out with `# DOMAIN=app.yourdomain.com` and comment: "Production domain for Caddy TLS (only used in docker-compose.prod.yaml context, D-04)".

    Do NOT remove or change any existing vars. Keep TEST_DATABASE_URL (used by integration tests). Update the comment above TEST_DATABASE_URL to clarify it is dev/test only and should not appear in the production .env.prod file.
  </action>
  <verify>
    <automated>grep -c "S3_ENDPOINT\|S3_BUCKET\|S3_ACCESS_KEY\|S3_SECRET_KEY\|S3_REGION\|S3_USE_PATH_STYLE\|MAX_UPLOAD_SIZE_MB\|DOMAIN" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/.env.example</automated>
  </verify>
  <done>
    backend/.env.example contains all 8 new var entries (S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB, DOMAIN). All original vars (DATABASE_URL, TEST_DATABASE_URL, SESSION_SECRET, PORT, ENV) are preserved. The grep count returns 8.
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Docker build context | Source code + assets COPY'd into builder; .env files must NOT be COPY'd |
| Runtime image → host env | Secrets injected via env_file at compose runtime; never baked into image layers |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-05 | Information Disclosure | Docker image layers | mitigate | No `COPY .env*` in Dockerfile; secrets arrive only via `env_file:` in compose at runtime (D-05). Add .env* to .dockerignore if one exists, or ensure no .env files exist in backend/ at build time |
| T-07-06 | Information Disclosure | .env.example committed to git | accept | .env.example contains only placeholder/dev values (no real secrets); this is intentional documentation |
| T-07-07 | Elevation of Privilege | distroless nonroot image | mitigate | `:nonroot` tag runs as uid 65532 (nonroot user); binary cannot write to filesystem; reduces blast radius if exploited |
| T-07-08 | Denial of Service | external CDN downloads in assets stage | accept | Tailwind, HTMX, Sortable.js downloads from unpkg/cdnjs during build; network failure fails the build but not production. Mitigation path: pin via checksums or vendor; accept for v1 |
</threat_model>

<verification>
Verify builder stage compiles:

```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && docker build -f Dockerfile --target builder -t xtablo-builder-test . && echo "BUILDER OK"
```

Verify .env.example completeness:

```
grep -E "S3_ENDPOINT|S3_BUCKET|S3_REGION|S3_ACCESS_KEY|S3_SECRET_KEY|S3_USE_PATH_STYLE|MAX_UPLOAD_SIZE_MB|DOMAIN" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/.env.example
```
</verification>

<success_criteria>
1. `backend/Dockerfile` exists with three stages named `assets`, `builder`, and final runtime
2. `docker build -f backend/Dockerfile --target builder backend/` succeeds (both binaries compile)
3. Final stage uses `gcr.io/distroless/static-debian12:nonroot`
4. Dockerfile contains `templ generate` at version `v0.3.1020`
5. Both `go build` commands have `CGO_ENABLED=0 GOOS=linux`
6. No `CMD` instruction in the final stage
7. `backend/.env.example` contains `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`, `S3_USE_PATH_STYLE`, `MAX_UPLOAD_SIZE_MB`, and `# DOMAIN=`
8. All original vars in `.env.example` are preserved
</success_criteria>

<output>
After completion, create `.planning/phases/07-deploy-v1/07-02-SUMMARY.md`
</output>
