Skip to content

Commit 1d3ef1a

Browse files
authored
chore: container hardening — read-only root FS + tmpfs /tmp + Python env (#119, #120, #170) (#68)
1 parent 829e954 commit 1d3ef1a

3 files changed

Lines changed: 25 additions & 1 deletion

File tree

Dockerfile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,14 @@ COPY --chown=app:app src/ src/
5353
USER app
5454

5555
# Put the venv on PATH so `uvicorn` resolves without `uv run` indirection.
56-
ENV PATH="/app/.venv/bin:${PATH}"
56+
# PYTHONDONTWRITEBYTECODE=1 stops Python from attempting `.pyc` writes under
57+
# `__pycache__/` on cold start — they would EROFS-fail under the read-only
58+
# root FS configured in docker-compose.yml. PYTHONUNBUFFERED=1 keeps
59+
# uvicorn's stdout from being held behind line-buffering when running under
60+
# non-TTY container stdio.
61+
ENV PATH="/app/.venv/bin:${PATH}" \
62+
PYTHONDONTWRITEBYTECODE=1 \
63+
PYTHONUNBUFFERED=1
5764

5865
EXPOSE 8000
5966

docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ services:
2323
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
2424
- OTEL_EXPORTER_OTLP_PROTOCOL=grpc
2525
- OTEL_SERVICE_NAME=harness-python-react
26+
# Container hardening. The root FS is read-only at the kernel level so a
27+
# post-exploit shell can't modify /app, persist binaries, or fill disk
28+
# under the `app` user's ownership. /tmp is the only writable path —
29+
# tmpfs-mounted with a 64 MB ceiling so it can't be abused as unbounded
30+
# storage. Verified: `touch /app/foo` → EROFS; `touch /tmp/foo` succeeds;
31+
# healthcheck reports healthy. See docs/SECURITY.md "Container Security".
32+
read_only: true
33+
tmpfs:
34+
- /tmp:size=64m,mode=1777
2635

2736
frontend:
2837
build: ./frontend

docs/SECURITY.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,16 @@ Tag v*.*.* ──► release.yml: build image, push to ghcr.io, generate
6161
- **Builder** — runs `uv sync --frozen --no-dev`. Has uv, pip cache, build tools.
6262
- **Runtime**`python:3.14-slim`, copies only `.venv` + `src/` from the builder, runs as non-root user `app`. No uv, no pip cache, no build tools, no dev deps.
6363

64+
Runtime stage env: `PYTHONDONTWRITEBYTECODE=1` (no `.pyc` writes — would EROFS-fail under the read-only root FS) and `PYTHONUNBUFFERED=1` (uvicorn stdout flushed immediately).
65+
66+
`docker-compose.yml`'s `app` service runs with `read_only: true` and a `tmpfs: /tmp:size=64m,mode=1777` mount. The kernel rejects writes to every path except the 64 MB tmpfs, so a post-exploit shell under the `app` user cannot modify `/app`, persist binaries, or fill the host's disk under `app`'s ownership. Verified locally: `touch /app/foo``Read-only file system`; `touch /tmp/foo` succeeds; healthcheck reports `healthy`.
67+
6468
Healthcheck uses stdlib `urllib.request` so curl isn't in the image.
6569

70+
### Distroless evaluation — deferred
71+
72+
`gcr.io/distroless/python3-debian12` ships Python at `/usr/bin/python3` while the current builder stage materialises a venv whose `pyvenv.cfg` and interpreter symlinks reference `/usr/local/bin/python3.14` (Dockerfile comment makes this constraint explicit). Migrating requires either matching Python paths between stages (no distroless variant matches slim's `/usr/local`) or rebuilding the venv inside the runtime stage (distroless has no `pip` / `uv`). Either route adds engineering risk and operational friction (no `docker exec ... sh`) that outweighs the marginal attack-surface reduction now that read-only-FS + non-root + no-build-tools + trivy-scanning are all in place. Revisit when distroless ships a `/usr/local` variant or when the venv-in-runtime cost shrinks.
73+
6674
## What's intentionally out of scope (scaffold)
6775

6876
- **WAF / DDoS** — deployment-environment concerns, not template concerns.

0 commit comments

Comments
 (0)