--- plan: 06-04 phase: 06-performance-production-hardening status: partial completed_at: 2026-06-04 commits: - 3788226 checkpoint_pending: task-3-human-verify --- # Plan 06-04: Container Hardening ## What was built (Tasks 1 & 2) ### backend/Dockerfile — multi-stage with appuser Replaced the single-stage root-running Dockerfile with a two-stage build: **Stage 1 `AS builder`:** - `FROM python:3.12-slim AS builder` - Installs `gcc` (build-only dep, discarded after this stage) with `--no-install-recommends` - Runs `pip install --no-cache-dir --prefix=/install -r requirements.txt` **Stage 2 `AS runtime`:** - `FROM python:3.12-slim AS runtime` - Installs runtime apt deps (tesseract-ocr, libgl1, libglib2.0-0) with `--no-install-recommends` - `COPY --from=builder /install /usr/local` (copies compiled packages, discards builder) - Creates `appgroup` gid=1000 and `appuser` uid=1000 (no login shell, no home dir) - `COPY --chown=appuser:appgroup . .` - `USER appuser` - `CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]` (no `--reload`) ### docker-compose.yml — runtime hardening Added to **backend** and **celery-worker** services (not celery-beat): ```yaml read_only: true tmpfs: - "/tmp:mode=1777" cap_drop: - ALL security_opt: - "no-new-privileges:true" ``` Added comment before celery-beat: ```yaml # celery-beat: NOT hardened — writes celerybeat-schedule to working directory (Phase 6 Pitfall 7). ``` Existing bind mounts (`./backend:/app`), env vars, healthchecks, and Loki logging labels preserved. ## Static verification (Tasks 1 & 2 acceptance criteria) | Check | Result | |-------|--------| | `FROM python:3.12-slim AS builder` | 1 ✓ | | `FROM python:3.12-slim AS runtime` | 1 ✓ | | `USER appuser` | 1 ✓ | | `useradd --uid 1000` | 1 ✓ | | `COPY --from=builder` | 1 ✓ | | `--no-install-recommends` occurrences | 2 ✓ | | `tesseract-ocr` in runtime only | 1 ✓ | | `--reload` in Dockerfile | 0 ✓ | | `docker compose config --quiet` | exit 0 ✓ | | `read_only: true` in backend block | 1 ✓ | | `/tmp:mode=1777` in backend block | 1 ✓ | | `no-new-privileges:true` in backend block | 1 ✓ | | `read_only: true` in celery-worker block | 1 ✓ | | hardening keys in celery-beat block | 0 ✓ | | `./backend:/app` bind mount preserved | 1 ✓ | ## Task 3: Human smoke test (PENDING) Docker daemon was not running at execution time. Task 3 requires: 1. `docker compose up -d --build` (rebuilds with new Dockerfile) 2. `docker compose exec -T backend id` → must show uid=1000(appuser) 3. `docker compose exec -T backend sh -c 'touch /readonly_probe 2>&1 || echo "rootfs is read-only"'` 4. `docker compose exec -T backend sh -c 'touch /tmp/ok && ls -la /tmp/ok'` 5. Same checks on celery-worker 6. Confirm celery-beat is writable (intentionally) 7. End-to-end upload test (proves tempfile.NamedTemporaryFile works under hardened runtime) Human responds "approved" to advance to Wave 3 (06-06). ## Satisfaction of requirements - D-07 (multi-stage non-root image): ✓ Tasks 1 & 2 complete - D-08 (read-only rootfs + tmpfs): ✓ Tasks 1 & 2 complete - D-09 (cap_drop ALL + no-new-privileges): ✓ Tasks 1 & 2 complete - Runtime verification (Task 3): PENDING human checkpoint