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