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
path
provides
contains
min_lines
backend/Dockerfile
Multi-stage builder/runtime image; runtime stage creates appuser uid=1000 and runs USER appuser
FROM python:3.12-slim AS builder
15
path
provides
contains
docker-compose.yml
Hardened backend + celery-worker service definitions (read_only/tmpfs/cap_drop/security_opt); celery-beat deliberately unmodified
read_only: true
from
to
via
pattern
backend/Dockerfile runtime stage
docker-compose.yml backend.read_only
appuser uid=1000 + tmpfs mode=1777 cooperate so non-root writes to /tmp succeed
USER appuser
from
to
via
pattern
docker-compose.yml backend.tmpfs
services/extractor.py tempfile.NamedTemporaryFile
/tmp:mode=1777 is the destination tempfile picks via TMPDIR/default
/tmp:mode=1777
from
to
via
pattern
docker-compose.yml celery-beat block
Pitfall 7 in 06-RESEARCH.md
EXPLICITLY NOT hardened — preserves writable filesystem for celerybeat-schedule
celery-beat:
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.
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).
Task 1: Rewrite backend/Dockerfile to multi-stage with appuser
backend/Dockerfile
- 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)
- The built image's default user is appuser (uid=1000), not root: `docker run --rm id` returns `uid=1000(appuser) gid=1000(appgroup)`.
- All Python imports from requirements.txt resolve in the runtime stage: `docker run --rm 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 tesseract --version` exits 0.
- The builder stage is discarded — final image does NOT contain gcc: `docker run --rm 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.
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).
cd backend && docker build -t docuvault-backend:phase6 . && docker run --rm docuvault-backend:phase6 id | grep -c "uid=1000(appuser)"
- `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 && 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).
Multi-stage image builds, runs as uid=1000, all production Python imports resolve, runtime apt deps present, build tooling absent.
Task 2: Apply read_only + tmpfs + cap_drop + no-new-privileges to backend and celery-worker in docker-compose.yml
docker-compose.yml
- 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)
- `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).
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.
docker compose config --quiet && awk '/^ backend:/,/^ [a-z]/' docker-compose.yml | grep -c "read_only: true"
- `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 && sleep 5 && docker compose exec -T backend sh -c 'touch /tmp/probe && rm /tmp/probe && echo TMP_OK'` prints "TMP_OK" (tmpfs writable).
- Smoke test (rootfs read-only): `docker compose exec -T backend sh -c 'touch /probe 2>&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".
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.
Task 3: Human smoke test — full stack up, upload + extract under hardened runtime
- .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
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.
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 && 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 && 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).
- 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).
Type "approved" if all checks pass; otherwise paste the failing command output for triage.
- `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.
<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>
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 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).