Container hardening: non-root user, read-only rootfs, digest-pinned base images #60

Closed
opened 2026-07-15 15:38:33 +00:00 by claude-bot · 1 comment
Contributor

From the 2026-07-15 review follow-ups. Cheap hardening for an internet-adjacent self-hosted app:

  • Run the app as a dedicated non-root user in the Dockerfile; ensure /app/data (and the backup dir) are writable by it
  • read_only: true rootfs in compose with explicit writable mounts (/app/data, tmpfs for /tmp if needed) — document any paths the app writes at runtime (Calendarific disk cache location, APScheduler jobstore, backups)
  • Pin base images by digest in the Dockerfile; Renovate manages digest bumps natively (pinDigests: true)
  • Optional: hadolint + trivy/grype scan as advisory CI steps (already suggested in #5's optional list)
From the 2026-07-15 review follow-ups. Cheap hardening for an internet-adjacent self-hosted app: - [ ] Run the app as a dedicated non-root user in the Dockerfile; ensure `/app/data` (and the backup dir) are writable by it - [ ] `read_only: true` rootfs in compose with explicit writable mounts (`/app/data`, tmpfs for `/tmp` if needed) — document any paths the app writes at runtime (Calendarific disk cache location, APScheduler jobstore, backups) - [ ] Pin base images by digest in the Dockerfile; Renovate manages digest bumps natively (`pinDigests: true`) - [ ] Optional: hadolint + trivy/grype scan as advisory CI steps (already suggested in #5's optional list)
claude-bot added this to the v1.1.0 milestone 2026-07-15 15:38:33 +00:00
Author
Contributor

Done — #117 merged, all seven CI jobs green (six existing plus the new Dockerfile lint).

⚠️ One manual step before the next deploy

The container now runs as uid 1000 and ./data is a host bind mount the old root container created:

cd ~/projects/Iris-WLED && sudo chown -R 1000:1000 ./data

Startup checks writability before running migrations and exits naming the uid it needs, so a missed chown is a clean stop rather than a half-applied migration or an opaque "attempt to write a readonly database". IRIS_UID/IRIS_GID in .env is the alternative if chowning is not possible.

Checkboxes

  • Non-root user — uid/gid 1000, fixed rather than distro-assigned because the number is what the host filesystem records for the bind mount. USER is numeric so a runtime enforcing runAsNonRoot can verify it.
  • read_only: true with explicit writable mounts — 64MB tmpfs for /tmp, plus cap_drop: ALL and no-new-privileges. Runtime write paths documented in docs/setup.md and confirmed by exercising each one: the SQLite database and WAL, the APScheduler jobstore (the same database), /app/data/backups, and the Calendarific cache.
  • Digest-pinned base images with pinDigests: true and auto-merged digest bumps.
  • hadolint + trivy — hadolint gates CI, trivy reports weekly from audit.yml.

Notes worth keeping

Digest pinning is only safe paired with the automation. An unmanaged pin is worse than the floating tag it replaces: python:3.14-slim is republished under the same tag whenever Debian patches something, so a stale pin silently stops receiving exactly the security rebuilds that justify pinning. Hence pinDigests and the auto-merge rule landing in the same change rather than as a follow-up.

CI boots the hardened container rather than inspecting the compose file. The E2E suite runs against --read-only --cap-drop ALL with an assertion that the uid is not 0. A runtime write path that only appears under load is exactly what a config-only assertion misses.

Two bugs found by building rather than reading

docker compose up --build was already broken for anyone who had followed CONTRIBUTING.md. .dockerignore patterns match from the context root, so the bare node_modules/ never matched frontend/node_modules; the context carried backend/.venv (153MB) and frontend/node_modules (217MB), and the symlink tree in node_modules/.bin failed the context load outright with invalid file request frontend/node_modules/.bin/acorn. Fixed with **/ prefixes — 342MB → 1.3MB.

Both HIGH Trivy findings were pip's, and there were two pips. The vendored msgpack and setuptools come from pip/_vendor, declared in pip's own bom.cdx.json — not from anything Iris installs, and unreachable since pip never executes in the container. Removing the venv's pip left the scan completely unchanged, which is how the base image's second copy in /usr/local surfaced. Both gone; the image scans clean.

One knock-on checked rather than assumed: upgrading the venv's seeded setuptools takes it past 81, which dropped pkg_resources. Nothing on the runtime path imports it — sentry_sdk's only reference is a < py3.8 fallback already inside except ImportError: return.

Incidental

Added a .gitattributes pinning *.sh to LF. The repo had none, so a Windows clone (git's default autocrlf=true there) would turn the entrypoint's shebang into #!/bin/sh\r and the image built from that tree fails at start with a "no such file or directory" naming an interpreter that plainly exists.

Done — #117 merged, all seven CI jobs green (six existing plus the new Dockerfile lint). ### ⚠️ One manual step before the next deploy The container now runs as uid 1000 and `./data` is a host bind mount the old root container created: ```sh cd ~/projects/Iris-WLED && sudo chown -R 1000:1000 ./data ``` Startup checks writability *before* running migrations and exits naming the uid it needs, so a missed chown is a clean stop rather than a half-applied migration or an opaque "attempt to write a readonly database". `IRIS_UID`/`IRIS_GID` in `.env` is the alternative if chowning is not possible. ### Checkboxes - [x] **Non-root user** — uid/gid 1000, fixed rather than distro-assigned because the number is what the host filesystem records for the bind mount. `USER` is numeric so a runtime enforcing `runAsNonRoot` can verify it. - [x] **`read_only: true` with explicit writable mounts** — 64MB tmpfs for `/tmp`, plus `cap_drop: ALL` and `no-new-privileges`. Runtime write paths documented in `docs/setup.md` and confirmed by exercising each one: the SQLite database and WAL, the APScheduler jobstore (the same database), `/app/data/backups`, and the Calendarific cache. - [x] **Digest-pinned base images** with `pinDigests: true` and auto-merged digest bumps. - [x] **hadolint + trivy** — hadolint gates CI, trivy reports weekly from `audit.yml`. ### Notes worth keeping **Digest pinning is only safe paired with the automation.** An unmanaged pin is worse than the floating tag it replaces: `python:3.14-slim` is republished under the same tag whenever Debian patches something, so a stale pin silently stops receiving exactly the security rebuilds that justify pinning. Hence `pinDigests` and the auto-merge rule landing in the same change rather than as a follow-up. **CI boots the hardened container rather than inspecting the compose file.** The E2E suite runs against `--read-only --cap-drop ALL` with an assertion that the uid is not 0. A runtime write path that only appears under load is exactly what a config-only assertion misses. ### Two bugs found by building rather than reading **`docker compose up --build` was already broken** for anyone who had followed CONTRIBUTING.md. `.dockerignore` patterns match from the context root, so the bare `node_modules/` never matched `frontend/node_modules`; the context carried `backend/.venv` (153MB) and `frontend/node_modules` (217MB), and the symlink tree in `node_modules/.bin` failed the context load outright with `invalid file request frontend/node_modules/.bin/acorn`. Fixed with `**/` prefixes — 342MB → 1.3MB. **Both HIGH Trivy findings were pip's, and there were two pips.** The vendored msgpack and setuptools come from `pip/_vendor`, declared in pip's own `bom.cdx.json` — not from anything Iris installs, and unreachable since pip never executes in the container. Removing the venv's pip left the scan *completely unchanged*, which is how the base image's second copy in `/usr/local` surfaced. Both gone; the image scans clean. One knock-on checked rather than assumed: upgrading the venv's seeded setuptools takes it past 81, which dropped `pkg_resources`. Nothing on the runtime path imports it — `sentry_sdk`'s only reference is a `< py3.8` fallback already inside `except ImportError: return`. ### Incidental Added a `.gitattributes` pinning `*.sh` to LF. The repo had none, so a Windows clone (git's default `autocrlf=true` there) would turn the entrypoint's shebang into `#!/bin/sh\r` and the image built from that tree fails at start with a "no such file or directory" naming an interpreter that plainly exists.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Iris-WLED#60
No description provided.