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