Files
agatha 12176471e1 Feat: Add production-grade multi-stage container image for API
Two-stage build (uv builder + python:3.12-slim runtime) with non-root
user (UID 1001), no dev deps, layer-cache-optimised dep install, and
graceful SIGTERM shutdown. Verified by api/tests/build/verify_production_image.sh
covering build, health endpoint, non-root, stdout logging, secret-free
layers, missing-env-var exit, and dep-layer cache hit. All 102 integration
tests still pass; shellcheck clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:59:29 +00:00

3.6 KiB

Quickstart: Production API Container Image

Prerequisites

  • Docker 24+ installed and running on the host
  • make available
  • A copy of .env (or the env vars from .env.example) for smoke-testing

Build the Production Image

make build-prod
# Equivalent: docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:latest

On a warm cache (deps unchanged), the build should complete in under 60 seconds because the dependency layer is reused.


Verify the Production Image (TDD Smoke Test)

make verify-prod

This runs api/tests/build/verify_production_image.sh, which:

  1. Builds the image (fails fast if Dockerfile.prod is missing — the red TDD state)
  2. Starts the container with test env vars
  3. Polls /api/v1/health until it returns 200 (or times out after 30s)
  4. Asserts the API process is running as a non-root user (UID ≠ 0)
  5. Sends SIGTERM and asserts the container exits with code 0 within 30s
  6. Asserts pytest is NOT importable inside the container (dev deps excluded)

Expected output (green):

[verify] Building reactbin-api-prod:test ...
[verify] Build OK
[verify] Starting container ...
[verify] Health check passed (GET /api/v1/health → 200)
[verify] Process user: 1001 (non-root ✓)
[verify] Sending SIGTERM ...
[verify] Container exited with code 0 (graceful shutdown ✓)
[verify] Dev deps absent ✓
[verify] All checks passed.

User Story Integration Scenarios

US1 — API Runs Reliably in Production

# Start container with real (or test) env vars
docker run --rm -d \
  --name reactbin-test \
  -p 8000:8000 \
  -e JWT_SECRET_KEY=my-secret \
  -e OWNER_USERNAME=owner \
  -e OWNER_PASSWORD=changeme \
  -e DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/db \
  -e S3_ENDPOINT_URL=http://minio:9000 \
  -e S3_BUCKET_NAME=reactbin \
  -e S3_ACCESS_KEY_ID=minioadmin \
  -e S3_SECRET_ACCESS_KEY=minioadmin \
  -e S3_REGION=us-east-1 \
  reactbin-api-prod:latest

# Check health
curl http://localhost:8000/api/v1/health
# → {"status":"ok"}

# Graceful shutdown
docker stop reactbin-test     # sends SIGTERM
docker wait reactbin-test     # → exit code 0

US2 — Minimal, Secure Container

# Verify non-root user
docker inspect --format='{{.Config.User}}' reactbin-api-prod:latest
# → appuser (or 1001)

# Verify no dev packages (pytest should not be importable)
docker run --rm reactbin-api-prod:latest \
  /app/.venv/bin/python -c "import pytest" 2>&1
# → ModuleNotFoundError: No module named 'pytest'

# Verify no source control or test files in image
docker run --rm reactbin-api-prod:latest ls /app
# → app  .venv   (no tests/, no alembic/, no .git/)

US3 — Fast, Reproducible Builds

# First build (cold): installs all deps
time docker build --no-cache -f api/Dockerfile.prod api/ -t reactbin-api-prod:cold

# Touch a source file only (no dep change)
touch api/app/main.py

# Second build: dependency layer served from cache
time docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:warm
# Expect: warm build < 30s; cold build varies (network-dependent)

# Confirm same health response from both
docker run --rm ... reactbin-api-prod:cold
docker run --rm ... reactbin-api-prod:warm

Missing Env Var Behaviour

docker run --rm \
  -e JWT_SECRET_KEY=my-secret \
  # OWNER_USERNAME intentionally omitted
  reactbin-api-prod:latest
# → Container exits non-zero, stderr logs: "field required: owner_username"

Read-Only Filesystem Compatibility

docker run --rm --read-only \
  -e JWT_SECRET_KEY=... [other env vars] \
  reactbin-api-prod:latest &

curl http://localhost:8000/api/v1/health
# → {"status":"ok"}