---
phase: 06-performance-production-hardening
plan: 04
type: execute
wave: 2
depends_on:
  - 06-02
files_modified:
  - backend/Dockerfile
  - docker-compose.yml
autonomous: false
requirements:
  - D-07
  - D-08
  - D-09
user_setup: []

must_haves:
  truths:
    - "backend image runs as uid=1000 (appuser), not root — docker run --rm <image> id returns uid=1000(appuser)"
    - "backend container's root filesystem is read-only (read_only: true) and writes to /tmp succeed because tmpfs is mounted mode=1777"
    - "celery-worker container has the same read_only + tmpfs + cap_drop + no-new-privileges hardening as backend"
    - "celery-beat is INTENTIONALLY left without read_only/cap_drop because it writes celerybeat-schedule to its working directory (Pitfall 7)"
    - "Both backend and celery-worker drop ALL Linux capabilities (cap_drop: [ALL]) and forbid privilege escalation (security_opt: no-new-privileges)"
    - "tempfile.NamedTemporaryFile (services/extractor.py) still succeeds inside the hardened container — the mode=1777 sticky-bit tmpfs is writable by appuser"
  artifacts:
    - path: "backend/Dockerfile"
      provides: "Multi-stage builder/runtime image; runtime stage creates appuser uid=1000 and runs USER appuser"
      contains: "FROM python:3.12-slim AS builder"
      min_lines: 15
    - path: "docker-compose.yml"
      provides: "Hardened backend + celery-worker service definitions (read_only/tmpfs/cap_drop/security_opt); celery-beat deliberately unmodified"
      contains: "read_only: true"
  key_links:
    - from: "backend/Dockerfile runtime stage"
      to: "docker-compose.yml backend.read_only"
      via: "appuser uid=1000 + tmpfs mode=1777 cooperate so non-root writes to /tmp succeed"
      pattern: "USER appuser"
    - from: "docker-compose.yml backend.tmpfs"
      to: "services/extractor.py tempfile.NamedTemporaryFile"
      via: "/tmp:mode=1777 is the destination tempfile picks via TMPDIR/default"
      pattern: "/tmp:mode=1777"
    - from: "docker-compose.yml celery-beat block"
      to: "Pitfall 7 in 06-RESEARCH.md"
      via: "EXPLICITLY NOT hardened — preserves writable filesystem for celerybeat-schedule"
      pattern: "celery-beat:"
---

<objective>
Harden the production container image (D-07) and the docker-compose runtime for backend + celery-worker (D-08, D-09). Replace the current single-stage root-running Dockerfile with a multi-stage build whose runtime stage creates appuser (uid=1000) and copies installed packages from a discardable builder stage. Add `read_only: true`, `tmpfs: /tmp:mode=1777`, `cap_drop: [ALL]`, and `security_opt: no-new-privileges:true` to the backend and celery-worker services in docker-compose.yml. Leave celery-beat untouched (Pitfall 7 — it writes celerybeat-schedule to its working directory).

Purpose: Satisfy Phase 6 success criteria 3 + 4 (non-root container, read-only rootfs, dropped capabilities). Reduce blast radius of a backend RCE: an attacker cannot write to the image filesystem, escalate privileges, or use a kernel capability to break out.

Output: A rewritten Dockerfile that produces a runtime image with appuser; docker-compose.yml service blocks that apply the four hardening keys to backend and celery-worker only; verification commands that prove uid=1000, read-only rootfs writes fail, and tmpfs writes succeed.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/06-performance-production-hardening/06-CONTEXT.md
@.planning/phases/06-performance-production-hardening/06-RESEARCH.md
@.planning/phases/06-performance-production-hardening/06-PATTERNS.md
@CLAUDE.md
@backend/Dockerfile
@docker-compose.yml
@backend/services/extractor.py
@backend/requirements.txt

<interfaces>
<!-- Key constraints the executor must respect. -->

Current Dockerfile shape (single stage, root user):
- FROM python:3.12-slim
- Runs apt-get install tesseract-ocr libgl1 libglib2.0-0 then `pip install -r requirements.txt`
- COPY . . and EXPOSE 8000 — no USER directive (defaults to root).

Multi-stage target shape (from RESEARCH.md Pattern 5):
- Stage 1 `builder` (python:3.12-slim) — installs build tooling (gcc) + `pip install --no-cache-dir --prefix=/install -r requirements.txt`.
- Stage 2 `runtime` (python:3.12-slim) — installs RUNTIME apt deps (tesseract-ocr, libgl1, libglib2.0-0), `COPY --from=builder /install /usr/local`, creates appuser via groupadd/useradd uid=1000, COPY --chown=appuser:appgroup . /app, `USER appuser`, EXPOSE 8000, CMD uvicorn.

docker-compose service shape (per RESEARCH.md Pattern 6 + Critical Note 4/5):
- Add to backend AND celery-worker:
  - `read_only: true`
  - `tmpfs: ["/tmp:mode=1777"]`  (long-form list with mode option; mode=1777 = world-writable + sticky bit so appuser uid=1000 can write tempfile.NamedTemporaryFile output)
  - `cap_drop: [ALL]`
  - `security_opt: ["no-new-privileges:true"]`
- DO NOT add to celery-beat.

Existing `volumes:` mount `./backend:/app` on backend and celery-worker is a bind mount; bind mounts are writable even when read_only=true on the container rootfs. The /app path remains writable for dev reload — verified by the docker spec (read_only applies to the image layer, not declared mounts).
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Rewrite backend/Dockerfile to multi-stage with appuser</name>
  <files>backend/Dockerfile</files>
  <read_first>
    - backend/Dockerfile (current single-stage form — line 1–17)
    - backend/requirements.txt (verify list of packages compatible with --prefix install)
    - .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pattern 5; Assumption A3 — pip --prefix copy)
    - .planning/phases/06-performance-production-hardening/06-PATTERNS.md (backend/Dockerfile section)
  </read_first>
  <behavior>
    - The built image's default user is appuser (uid=1000), not root: `docker run --rm <image> id` returns `uid=1000(appuser) gid=1000(appgroup)`.
    - All Python imports from requirements.txt resolve in the runtime stage: `docker run --rm <image> python -c "import fastapi, structlog, sqlalchemy, minio, celery"` exits 0.
    - Runtime system deps are installed in the runtime stage (tesseract-ocr, libgl1, libglib2.0-0): `docker run --rm <image> tesseract --version` exits 0.
    - The builder stage is discarded — final image does NOT contain gcc: `docker run --rm <image> sh -c "command -v gcc" || true` returns empty.
    - The runtime stage uses `--no-install-recommends` to keep apt footprint minimal.
    - Image still exposes port 8000 and CMD launches uvicorn with the same host/port as before.
  </behavior>
  <action>
    Replace backend/Dockerfile content with a two-stage build per RESEARCH.md Pattern 5.
    Stage 1 header: `FROM python:3.12-slim AS builder`. WORKDIR /build. Install build tooling: `apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*`. Copy requirements.txt. Run `pip install --no-cache-dir --prefix=/install -r requirements.txt`.
    Stage 2 header: `FROM python:3.12-slim AS runtime`. Install RUNTIME apt deps: `apt-get update && apt-get install -y --no-install-recommends tesseract-ocr libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*`. `COPY --from=builder /install /usr/local`. Create non-root user: `RUN groupadd --gid 1000 appgroup && useradd --uid 1000 --gid appgroup --shell /bin/sh --no-create-home appuser`. WORKDIR /app. `COPY --chown=appuser:appgroup . .`. `USER appuser`. `EXPOSE 8000`. `CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]`.
    Per the docker-compose `command:` override (`uvicorn main:app --host 0.0.0.0 --port 8000 --reload`), the CMD is replaced at runtime in dev — but the Dockerfile CMD remains the production default. Do NOT bake `--reload` into the CMD.
    Use `--no-install-recommends` on BOTH apt-get invocations.
    Do NOT add the locust dependency anywhere in this Dockerfile (per 06-03 boundary: locust lives in requirements-dev.txt only).
  </action>
  <verify>
    <automated>cd backend &amp;&amp; docker build -t docuvault-backend:phase6 . &amp;&amp; docker run --rm docuvault-backend:phase6 id | grep -c "uid=1000(appuser)"</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "FROM python:3.12-slim AS builder" backend/Dockerfile` returns 1.
    - `grep -c "FROM python:3.12-slim AS runtime" backend/Dockerfile` returns 1.
    - `grep -c "^USER appuser" backend/Dockerfile` returns 1.
    - `grep -c "useradd --uid 1000" backend/Dockerfile` returns 1.
    - `grep -c "COPY --from=builder" backend/Dockerfile` returns 1.
    - `grep -c "no-install-recommends" backend/Dockerfile` returns 2 (both apt-get blocks).
    - `grep -c "tesseract-ocr" backend/Dockerfile` returns 1 (in runtime stage only — NOT in builder stage; the builder needs only gcc).
    - `grep -c "EXPOSE 8000" backend/Dockerfile` returns 1.
    - `grep -c "CMD .uvicorn" backend/Dockerfile` returns 1.
    - `grep -c "\\--reload" backend/Dockerfile` returns 0 (reload is a compose-level override only).
    - Build succeeds: `cd backend &amp;&amp; docker build -t docuvault-backend:phase6 .` exits 0.
    - `docker run --rm docuvault-backend:phase6 id` output contains `uid=1000(appuser)`.
    - `docker run --rm docuvault-backend:phase6 python -c "import fastapi, structlog, sqlalchemy, minio, celery, structlog"` exits 0 (verifies A3 — the --prefix/COPY pattern populated site-packages correctly).
    - `docker run --rm docuvault-backend:phase6 tesseract --version` exits 0 (runtime apt deps present).
    - `docker run --rm docuvault-backend:phase6 sh -c "command -v gcc"` exits non-zero or returns empty (gcc was in builder stage only).
  </acceptance_criteria>
  <done>Multi-stage image builds, runs as uid=1000, all production Python imports resolve, runtime apt deps present, build tooling absent.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Apply read_only + tmpfs + cap_drop + no-new-privileges to backend and celery-worker in docker-compose.yml</name>
  <files>docker-compose.yml</files>
  <read_first>
    - docker-compose.yml (lines 49–104 — backend, celery-worker, celery-beat blocks; volumes block)
    - .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pattern 6 hardening additions; Pitfall 1, 3, 6, 7; Critical Note 5)
    - .planning/phases/06-performance-production-hardening/06-PATTERNS.md (docker-compose.yml section; Critical Note 4)
    - backend/services/extractor.py (line 18 area — tempfile.NamedTemporaryFile usage that drives the mode=1777 requirement)
  </read_first>
  <behavior>
    - `docker compose config --quiet` exits 0 (compose file is still valid YAML/schema).
    - After `docker compose up backend`, `docker compose exec backend sh -c 'touch /tmp/probe && echo ok'` writes successfully (tmpfs writable by appuser).
    - After `docker compose up backend`, `docker compose exec backend sh -c 'touch /probe 2>&1 || echo readonly'` reports "readonly" (rootfs is read_only).
    - `docker compose exec backend cat /proc/self/status | grep CapEff` shows an empty/zero effective capability set (cap_drop: ALL took effect).
    - The celery-beat block contains NEITHER `read_only:` NOR `cap_drop:` NOR `tmpfs:` keys (Pitfall 7 — celerybeat-schedule must remain writable).
    - The celery-worker block has the SAME four hardening keys as backend.
    - The backend bind mount `./backend:/app` remains and continues to provide live source for `--reload` dev mode (bind mounts are unaffected by `read_only: true` on the container rootfs).
  </behavior>
  <action>
    Edit docker-compose.yml in place. For the backend service block (currently lines 49–79), add the following four keys as siblings of existing keys (build, ports, volumes, environment, command, depends_on). Insert after `depends_on:` (or at a stable, readable location):
      - `read_only: true`
      - `tmpfs:` with a single list entry `"/tmp:mode=1777"` (use the long-form string with mode option — mode=1777 makes the tmpfs world-writable with the sticky bit so appuser uid=1000 can write the tempfile.NamedTemporaryFile output that services/extractor.py creates).
      - `cap_drop:` with a single list entry `ALL`.
      - `security_opt:` with a single list entry `no-new-privileges:true`.
    For the celery-worker block (currently lines 81–103), add the SAME four keys with the SAME values. The celery worker runs the same image and the same temp-file-producing extractor; it needs identical hardening.
    For the celery-beat block (currently lines 105–124), DO NOT add any of those four keys. Leave the block exactly as-is. Add a single comment line directly above the celery-beat key declaration: `  # celery-beat: NOT hardened — writes celerybeat-schedule to working directory (Phase 6 Pitfall 7).`
    Do NOT remove or modify the existing `volumes: - ./backend:/app` bind mounts — bind mounts remain writable under read_only because read_only applies only to the image layer.
    Do NOT remove or modify environment variables, healthchecks, or extra_hosts.
    Do NOT add the Loki/Promtail/Grafana labels here — those landed in 06-02 already (06-02 added `labels: logging: "promtail"` on backend and celery-worker). If those labels are missing because 06-02 has not yet shipped, ADD them now alongside the four hardening keys to keep the two plans composable — but if they are already present, leave them untouched.
  </action>
  <verify>
    <automated>docker compose config --quiet &amp;&amp; awk '/^  backend:/,/^  [a-z]/' docker-compose.yml | grep -c "read_only: true"</automated>
  </verify>
  <acceptance_criteria>
    - `docker compose config --quiet` exits 0.
    - `awk '/^  backend:/,/^  [a-z][a-z-]*:/' docker-compose.yml | grep -c "read_only: true"` returns 1.
    - `awk '/^  backend:/,/^  [a-z][a-z-]*:/' docker-compose.yml | grep -cE "/tmp:mode=1777"` returns 1.
    - `awk '/^  backend:/,/^  [a-z][a-z-]*:/' docker-compose.yml | grep -cE "^\\s*-\\s*ALL\\s*$"` returns 1.
    - `awk '/^  backend:/,/^  [a-z][a-z-]*:/' docker-compose.yml | grep -cE "no-new-privileges:true"` returns 1.
    - `awk '/^  celery-worker:/,/^  [a-z][a-z-]*:/' docker-compose.yml | grep -c "read_only: true"` returns 1.
    - `awk '/^  celery-worker:/,/^  [a-z][a-z-]*:/' docker-compose.yml | grep -cE "/tmp:mode=1777"` returns 1.
    - `awk '/^  celery-worker:/,/^  [a-z][a-z-]*:/' docker-compose.yml | grep -cE "no-new-privileges:true"` returns 1.
    - celery-beat must have NONE of these keys: `awk '/^  celery-beat:/,/^  [a-z][a-z-]*:/' docker-compose.yml | grep -cE "read_only:|cap_drop:|tmpfs:|security_opt:|no-new-privileges"` returns 0.
    - Bind mount preserved: `awk '/^  backend:/,/^  [a-z][a-z-]*:/' docker-compose.yml | grep -c "./backend:/app"` returns 1.
    - Smoke test (runtime): `docker compose up -d backend &amp;&amp; sleep 5 &amp;&amp; docker compose exec -T backend sh -c 'touch /tmp/probe &amp;&amp; rm /tmp/probe &amp;&amp; echo TMP_OK'` prints "TMP_OK" (tmpfs writable).
    - Smoke test (rootfs read-only): `docker compose exec -T backend sh -c 'touch /probe 2>&amp;1; ls /probe 2>/dev/null || echo ROOTFS_RO'` prints "ROOTFS_RO".
    - Smoke test (extractor): upload a small PDF via the API and confirm classify works (proves tempfile.NamedTemporaryFile succeeded under the new constraints). If a full upload is heavy, instead `docker compose exec -T backend python -c "import tempfile; t = tempfile.NamedTemporaryFile(delete=False); t.write(b'x'); t.close(); print('TEMPFILE_OK')"` exits 0 and prints "TEMPFILE_OK".
  </acceptance_criteria>
  <done>Backend and celery-worker run read_only with mode=1777 tmpfs /tmp, all capabilities dropped, no privilege escalation; celery-beat untouched; live-reload bind mount still works; extractor's tempfile pattern still succeeds.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: Human smoke test — full stack up, upload + extract under hardened runtime</name>
  <read_first>
    - .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pitfall 6 — PyMuPDF cache paths; Pitfall 1 — tmpfs mode)
    - backend/services/extractor.py
    - backend/tasks/document_tasks.py
  </read_first>
  <what-built>
    The Dockerfile produces a uid=1000 runtime image; docker-compose.yml runs backend and celery-worker with read_only rootfs, tmpfs /tmp mode=1777, ALL capabilities dropped, no-new-privileges enabled. Celery-beat is intentionally left writable.
  </what-built>
  <how-to-verify>
    1. From repo root: `docker compose down -v` then `docker compose up -d --build` and wait ~30s.
    2. Confirm appuser: `docker compose exec -T backend id` should print `uid=1000(appuser) gid=1000(appgroup) groups=1000(appgroup)`.
    3. Confirm read_only rootfs: `docker compose exec -T backend sh -c 'touch /readonly_probe 2>&1 || echo "rootfs is read-only"'` should print "rootfs is read-only".
    4. Confirm tmpfs writable: `docker compose exec -T backend sh -c 'touch /tmp/ok &amp;&amp; ls -la /tmp/ok'` should succeed.
    5. Confirm celery-worker has the same hardening: `docker compose exec -T celery-worker id` returns uid=1000; `docker compose exec -T celery-worker sh -c 'touch /readonly_probe 2>&1 || echo RO'` prints RO; `docker compose exec -T celery-worker sh -c 'touch /tmp/ok'` succeeds.
    6. Confirm celery-beat is UNHARDENED (intentionally): `docker compose exec -T celery-beat sh -c 'touch /tmp/probe &amp;&amp; touch /app/probe; ls /app/probe'` should succeed (writable rootfs — required for celerybeat-schedule).
    7. End-to-end upload test: register a fresh user via the UI (or curl /api/auth/register), log in, upload a small PDF, wait ~10s for the Celery task. Confirm in the UI/API that the document appears with extracted text and classified topics. This validates Pitfall 6 (PyMuPDF cache under read_only) is not blocking extraction.
    8. Inspect docker stats: `docker stats --no-stream` — backend and celery-worker should be running healthy (not restarting in a loop).
    9. Inspect logs for any "Read-only file system" or "Permission denied" errors: `docker compose logs backend celery-worker | grep -iE "read-only|permission denied"` — should be empty (or only contain expected ignored writes, not failures).
  </how-to-verify>
  <acceptance_criteria>
    - User confirms steps 2–8 all pass; step 9 produces zero hard errors that crash request handling.
    - If step 7 (extraction) fails with a Read-only/Permission error, user reports it — planner adds an additional tmpfs mount (e.g. `/var/cache/fontconfig`) in a follow-up before approving.
    - User responds "approved" to advance Wave 3 (06-06 security gate + RUNBOOK).
  </acceptance_criteria>
  <resume-signal>Type "approved" if all checks pass; otherwise paste the failing command output for triage.</resume-signal>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Attacker exploits backend RCE → container filesystem | Hardening (read_only + cap_drop + no-new-privileges) limits post-RCE actions |
| Container process → host kernel | Dropped capabilities + no privilege escalation make kernel-API abuse harder |
| Container process → other containers on same network | Out of scope for hardening (compose default bridge network) — network policy is Phase 7+ |
| Build-time gcc → runtime image | Multi-stage build keeps build tools out of the runtime image, reducing CVE attack surface |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-04-01 | Elevation of Privilege | Container escape via root-owned process exploit | mitigate | Multi-stage Dockerfile creates appuser uid=1000; USER appuser drops root before CMD runs (D-07) |
| T-06-04-02 | Tampering | Attacker writes a webshell or modifies application code on container filesystem | mitigate | read_only: true on backend + celery-worker prevents all writes outside declared tmpfs/volumes (D-08) |
| T-06-04-03 | Elevation of Privilege | Attacker uses Linux capability (CAP_NET_RAW, CAP_SYS_PTRACE, etc) to expand reach | mitigate | cap_drop: [ALL]; no cap_add — port 8000 is unprivileged (D-09) |
| T-06-04-04 | Elevation of Privilege | Attacker invokes setuid binary to regain root inside container | mitigate | security_opt: no-new-privileges:true blocks setuid bit from elevating (D-09 belt-and-braces) |
| T-06-04-05 | Denial of Service | tempfile.NamedTemporaryFile fails under read_only because /tmp is not writable by appuser | mitigate | tmpfs: /tmp:mode=1777 — sticky world-writable bit per Pitfall 1 (RESEARCH.md) — verified by Task 3 step 4 |
| T-06-04-06 | Denial of Service | celery-beat cannot write celerybeat-schedule under read_only | accept | celery-beat INTENTIONALLY left unhardened per Pitfall 7; security risk lower (no inbound network port); RUNBOOK.md (06-06) documents the deferral |
| T-06-04-07 | Information Disclosure | Build tooling (gcc) shipped in runtime image expands CVE surface | mitigate | Multi-stage build discards builder; runtime image contains only runtime apt deps + Python site-packages |
</threat_model>

<verification>
- `docker compose config --quiet` exits 0.
- `cd backend && docker build -t docuvault-backend:phase6 .` exits 0; resulting image `id` returns uid=1000(appuser).
- backend and celery-worker blocks each contain `read_only: true`, `/tmp:mode=1777` tmpfs, `cap_drop: [ALL]`, `security_opt: no-new-privileges:true`.
- celery-beat block contains NONE of those four keys.
- Live smoke (Task 3) confirms /tmp writable, rootfs writes fail, full upload+extract round-trip works.
- Existing backend test suite still passes (no test regressions): `cd backend && pytest tests/ --no-header -q` baseline unchanged.
</verification>

<success_criteria>
- D-07 satisfied: Dockerfile is multi-stage with appuser uid=1000; runtime image contains only runtime deps + Python packages.
- D-08 satisfied: backend + celery-worker run with `read_only: true` and `tmpfs: /tmp:mode=1777`; celery-beat preserved as writable.
- D-09 satisfied: backend + celery-worker drop ALL capabilities and forbid privilege escalation.
- No regression: existing pytest suite green; full Docker stack starts; upload + extract works end-to-end under the hardened runtime.
</success_criteria>

<output>
Create `.planning/phases/06-performance-production-hardening/06-04-SUMMARY.md` when done. Include: built image ID + size before/after multi-stage, `docker run --rm <image> id` output, `docker compose config --quiet` exit code, smoke test results from Task 3 (each numbered step + pass/fail), explicit confirmation that celery-beat was left untouched, and any tmpfs additions made beyond /tmp:mode=1777 (e.g. /var/cache/fontconfig if Pitfall 6 surfaced).
</output>
