Skip to content

Harden prod Docker image: drop toolchain, read-only rootfs, least privilege#37

Open
radiantnode wants to merge 10 commits into
mainfrom
claude/docker-hardening-33204a
Open

Harden prod Docker image: drop toolchain, read-only rootfs, least privilege#37
radiantnode wants to merge 10 commits into
mainfrom
claude/docker-hardening-33204a

Conversation

@radiantnode

Copy link
Copy Markdown
Owner

Shrinks the prod runtime attack surface and runs the stack with the smallest possible privileges and a read-only root filesystem.

Dockerfile

  • Toolchain out of the runtime image (the big win): build-essential + cmake (which compile asyncpg/blspy) now live only in a throwaway builder stage that produces a self-contained venv; the runtime stage copies just that venv. The compiler, headers, and apt metadata no longer ship — the single largest piece of runtime surface.
  • Tightened copy: only main.py, server/, migrations/, static/ ship. scripts/, ops/, tools/, tests/, loadtest.py, and the build/compose files stay out of the image.
  • Stripped every setuid/setgid bit so a compromised process can't reuse a privileged helper to escalate.
  • nginx → nginxinc/nginx-unprivileged: the master runs as a non-root user (uid 101) on a high port, so the container needs no caps and can run read-only (this was the last root process in the stack).

ops/nginx.conf

  • Listen on 8080 (non-root) and route the pid, all temp dirs, and logs to /tmp + stdout/stderr so the root FS can be read-only.

docker-compose.prod.yml

  • web + nginx: read_only root FS with a small /tmp tmpfs, cap_drop: [ALL], no-new-privileges; web also gets init: true and an HTTP healthcheck. The nginx published port now maps to 8080.
  • redis (persistence already off), prometheus, postgres_exporter: read_only, cap_drop: [ALL], no-new-privileges; redis runs as the redis user to avoid needing CAP_SET[UG]ID.
  • postgres + grafana: no-new-privileges only — their privilege-dropping entrypoints and writable state make a full read-only/cap-drop pass a separate, test-heavy change (noted inline).

Verification

  • Both images build with no compiler present and no setuid binaries.
  • Ran the hardened web (327 MB) and nginx against real Redis/Postgres with --read-only --cap-drop ALL --security-opt no-new-privileges: web serves / → 200, nginx proxies / → 200 and serves /static → 200, running as uid 101.
  • docker compose -f docker-compose.prod.yml config parses.

Follow-ups (intentionally out of scope)

  1. Full read-only/cap-drop for postgres + grafana (needs the right cap_add subset + tmpfs tuning — deserves its own tested change).
  2. A Trivy/grype CVE scan in CI to back the tag-pin "pick up patches" base-image policy.

🤖 Generated with Claude Code

https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX


Generated by Claude Code

claude and others added 10 commits June 20, 2026 20:24
…vilege

Shrink the runtime attack surface and run the prod stack with the smallest
possible privileges and a read-only root filesystem.

Dockerfile:
- Split the Python image into a builder stage (build-essential + cmake, which
  compile asyncpg/blspy into a self-contained venv) and a clean runtime stage
  that copies only that venv. The compiler, headers, and apt metadata no longer
  ship in the runtime image — the single biggest piece of runtime surface.
- Copy only the runtime files (main.py, server/, migrations/, static/) instead
  of the whole repo; scripts/, ops/, tools/, tests/, loadtest.py, and the
  build/compose files stay out of the image.
- Strip every setuid/setgid bit so a compromised process can't reuse a
  privileged helper to escalate.
- Switch the nginx stage to nginxinc/nginx-unprivileged: the master runs as a
  non-root user (uid 101) on a high port, so the container needs no caps and can
  run read-only.

ops/nginx.conf: listen on 8080 (non-root), route the pid, all temp dirs, and
logs to /tmp + stdout/stderr so the root FS can be read-only.

docker-compose.prod.yml:
- web + nginx: read_only root FS with a small /tmp tmpfs, cap_drop ALL,
  no-new-privileges; web also gets init: true and an HTTP healthcheck. nginx
  published port now maps to 8080.
- redis (persistence already off), prometheus, postgres_exporter: read_only,
  cap_drop ALL, no-new-privileges; redis runs as the redis user to avoid needing
  CAP_SET[UG]ID.
- postgres + grafana: no-new-privileges (their privilege-dropping entrypoints
  and writable state make a full read-only/cap-drop pass a separate change).

Verified: both images build with no compiler present and no setuid binaries;
web (327MB) and nginx serve 200 on a read-only rootfs with all caps dropped and
no-new-privileges; nginx runs as uid 101 and serves /static; prod compose parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX
Builds on the image hardening with runtime least-privilege controls.

Resource + pid limits (DoS containment): every service now caps cpus, memory,
and pids_limit (top-level keys, honored by `docker compose up`), so a runaway or
compromised container can't exhaust host CPU/RAM or fork-bomb the box.

Secrets as files, not env vars: app-owned secrets (JWT_SECRET, STATS_TOKEN,
METRICS_TOKEN) are delivered to the web container as files under /run/secrets via
compose `secrets:`, so they no longer appear in `docker inspect` or
/proc/<pid>/environ. server/config.py gains a `_secret()` helper that reads
`{NAME}_FILE` (a path) first, falling back to the `{NAME}` env var then a default
— an unreadable/empty file falls back rather than silently blanking a secret.
The helper is also applied (opt-in) to REDIS_URL, POSTGRES_DSN, GRAFANA_PASS, and
DISCORD_BOT_TOKEN so operators can file-back those too; the redis/postgres/grafana
passwords stay env-driven for now since those images/URLs consume them directly.

tmpfs hardening: the read-only containers' one writable path (/tmp) is mounted
noexec,nosuid,nodev so it can't be used to stage and execute a dropped binary.

.env.prod.example documents the new secret-file flow (materialise the three files
under the gitignored ops/secrets/ before `up`).

Verified: prod compose renders valid; the web image serves 200 on a read-only
rootfs with a noexec /tmp, pids cap, and JWT loaded from a mounted file (absent
from the container env); _secret() file/env/default fallbacks confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX
… deps, image-signing CI

Completes the deployment hardening pass.

Network egress split: a new two-network topology. `internal` is egress-disabled
(internal: true) and holds the data stores — redis, postgres, postgres_exporter,
prometheus — so a compromised one has no route to the internet (can't exfiltrate
or pull a second stage). `edge` (the only network with a gateway) is joined only
by web (drand/Discord egress), nginx (public ingress port), and grafana (admin UI
port; its phone-home is disabled via GF_ANALYTICS_*). web sits on both.

Full read-only + cap-drop for the last two services:
  - postgres: read_only root with the data dir on its volume and /tmp + the socket
    dir on tmpfs; cap_drop ALL + the minimal cap_add the root entrypoint needs to
    chown the data dir and gosu-drop (CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID).
  - grafana: read_only root with /tmp tmpfs (writes its sqlite/plugins to the
    volume); cap_drop ALL (already non-root).
Every service is now read_only with all caps dropped (postgres keeps the minimal
add-back), no-new-privileges, and cpu/memory/pids caps.

All credentials are now mounted files, never env vars (so none appear in
`docker inspect` / /proc/<pid>/environ):
  - redis reads its password from a secret and writes a tiny config on tmpfs, so
    the password never lands in argv (vs --requirepass on the command line).
  - postgres uses POSTGRES_PASSWORD_FILE; postgres_exporter uses the split
    DATA_SOURCE_URI/USER + DATA_SOURCE_PASS_FILE form.
  - grafana uses GF_SECURITY_ADMIN_PASSWORD__FILE and reads the datasource
    password via the $__file{} provisioning provider.
  - web reads the full redis URL / postgres DSN / grafana pass from files
    (REDIS_URL_FILE etc.) via config._secret(), alongside jwt/stats/metrics.

Hashed, reproducible dependency install: requirements.lock is regenerated from
requirements.txt with pip-compile --generate-hashes (it was stale and unhashed —
e.g. asyncpg 0.30 vs the declared 0.31). The Dockerfile installs it with
--require-hashes so a tampered or MITM'd index can't substitute an artifact.

Supply-chain CI (.github/workflows/image-security.yml): every PR builds the web
image, fails on fixable HIGH/CRITICAL CVEs (Trivy), and uploads an SPDX SBOM
(Syft). On main, both images are pushed to GHCR with SLSA provenance + SBOM
attestations and keyless-signed with cosign (Sigstore OIDC, no long-lived keys).

Verified end-to-end: brought up the full prod compose with generated secret
files — postgres + redis reach Healthy (read-only postgres, file passwords work),
migrations apply, web serves 200 through nginx, grafana serves 200 (datasource
provisioned via $__file{}). Confirmed: the four data stores are on `internal`
only with NO default route (egress blocked) while edge has one; web's env exposes
only *_FILE pointers (no credential values); the redis password is absent from
its process argv; all seven containers are ReadonlyRootfs with 0 restarts. The
web image builds with --require-hashes (all 37 packages hash-verified); prod
compose renders valid; ruff passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX
aquasecurity/trivy-action tags are v-prefixed (v0.29.0 … v0.36.0); the unprefixed
0.29.0 doesn't resolve, so the scan job failed at action setup. Pin to v0.36.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX
The new image-security scan (Trivy, fail on fixable HIGH/CRITICAL) flagged the
base image's OS packages: a CRITICAL openssl heap overflow (CVE-2026-31789),
several HIGH openssl RCE/UAF/DoS issues, and perl-base — all with fixed Debian
versions available. The pinned python:3.12.8-slim-bookworm tag lags Debian's
security repo, so a fresh build still ships these.

Add `apt-get update && apt-get -y upgrade` (apt lists removed after) to the web
runtime stage. Verified locally: openssl/libssl3 -> 3.0.20-1~deb12u2 and
perl-base -> 5.36.0-7+deb12u3 (exactly the fixed versions), and a local Trivy run
(HIGH,CRITICAL, --ignore-unfixed) now reports 0 findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX
CodeQL "Clear-text logging of sensitive information" (high) flagged
gamestore.py: the connect log emitted REDIS_URL verbatim, which embeds the
password (redis://:PASSWORD@host). Surfaced once config._secret() made CodeQL
treat REDIS_URL as sensitive, but it was a real leak — the password landed in
the app logs on every startup.

Drop the URL from the success log (log a plain "connected" line; the host is
static config and the confirmation is the useful signal). The failure path keeps
an actionable target via a new _redact() helper that strips credentials
(scheme://host:port only) — used in the RuntimeError message, which is not a
logging sink. No REDIS_URL-derived value reaches a log sink anymore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX
Core end-to-end suite (gameplay, multiplayer sync, pause, player + host
reconnect, WebAuthn auth) run against docker-compose.prod.yml — read-only
containers, file-based secrets, egress-split networks, nginx-unprivileged.
24/24 executed checks passed; hardening properties confirmed live (no Redis
password in logs, read-only Postgres accepted the WebAuthn user write, data
stores have no egress, no secret values in the web env).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX
Full 18-step telemetry pipeline test run against docker-compose.prod.yml —
token-gated /metrics, internal-only Prometheus, Grafana datasource password via
$__file{} on a read-only container, file-based secrets. 18/18 passed: Prometheus
scrapes the gated /metrics via the mounted token file over the egress-split
network (rolls 99=99 exact), Postgres datasource connects, all 5 dashboards
render error-free, 0 telemetry drops / 0 live-push failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX
…ed-stack log

Three additional checks on the hardened prod stack, all PASS:
- Host transfer after 60s DISCONNECT_GRACE: role reassigns to the remaining player.
- Read-only Postgres restart on an existing volume: comes up via the non-initdb
  path under ReadonlyRootfs with data intact and password-from-file.
- Scale to 3 read-only/capped replicas: cross-instance sharing works (create on
  web-2, join on web-1, roll fans out across them via Redis pub/sub over the
  egress-split internal net); 0 restarts / no OOM. A single-IP synthetic flood
  was correctly throttled by the per-IP abuse caps (not a throughput benchmark).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants