Harden a Base Image with Non-Root User and Read-Only Rootfs
Run containers as a dedicated UID with a read-only root filesystem, writable tmpfs mounts, and dropped capabilities verified by a breakout attempt.
Task
Apply non-root execution, read-only rootfs, capability drops, and verify the constraints hold against escape attempts.
When to use this
A container escape write-up showed that the app ran as root with a writable filesystem and every capability default, turning one RCE into host compromise.
Prerequisites
- Docker or Kubernetes with securityContext support
- An application that tolerates read-only filesystem (or configurable writable paths)
- Permission to rebuild and redeploy the image
Steps
1. Create and switch to a dedicated user in the Dockerfile.
RUN groupadd -g 10001 app && useradd -u 10001 -g app -s /usr/sbin/nologin app
USER 10001:10001
Expected output: The process runs as UID 10001, so a compromised process starts with no root authority inside the container.
2. Mount the root filesystem read-only with tmpfs for writes.
docker run --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /app/cache:rw,noexec,nosuid,size=128m \
app:2.4.1
Expected output: The image cannot be modified at runtime; the only writable paths are explicit tmpfs mounts with size caps.
3. Drop all capabilities and forbid privilege escalation.
securityContext:
runAsNonRoot: true
runAsUser: 10001
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
Expected output: The pod spec enforces the same constraints declaratively, surviving orchestrator restarts and copy-paste deploys.
4. Verify by attempting to break the constraints.
docker exec app sh -c "id && touch /etc/passwd" 2>&1 | head -3Expected output: The id shows uid 10001, and the touch fails with read-only filesystem: both constraints are real, not decorative.
5. Confirm the app still writes where it legitimately needs to.
docker exec app sh -c "touch /tmp/probe && ls -la /tmp/probe"Expected output: Writes inside the tmpfs mounts succeed, proving the whitelist covers real application needs.
Troubleshooting
Problem
App crashes trying to write config or logs
Solution
Its write paths were implicit; enumerate them, mount each as tmpfs or a volume, and prefer converting the app to env-config and stdout logs.
Problem
Base image has no useradd tooling
Solution
Use a numeric USER directive directly, which requires no tooling in the final image, or pick a base that ships a non-root user by default.
Problem
Needs to bind a low port
Solution
Either drop the capability requirement by using a high port behind the service mesh, or add back exactly CAP_NET_BIND_SERVICE instead of running as root.
Limitations
- These are container hardening layers, not isolation guarantees; kernel-level escapes still require patched hosts.
- Read-only rootfs pushes state into mounts; an app with pervasive writes needs refactoring before it fits.
Assume the process is already compromised
Hardening is not about preventing the exploit; it is about what the attacker gets after it. Running as UID 10001 with a read-only root filesystem and zero capabilities means the compromised process can read its own code and write to two tmpfs mounts. Every layer removed from that starting position is damage that simply cannot occur.
securityContext:
runAsNonRoot: true
runAsUser: 10001
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
Test the prison from inside
Constraints you have not tried to violate are decorations. Exec into the hardened container and attempt the obvious: write to /etc, read another UID files, open a raw socket. Each failure is the hardening working. Making this a scheduled drill also catches images that silently regressed to defaults when someone rebuilt from a stale base.
Whitelist writes deliberately
Read-only rootfs fails only when the application writes somewhere you did not plan for. The fix is never to abandon the read-only setting but to enumerate the real write paths and mount each one explicitly. The exercise usually reveals accidental writes, like cache or PID files, that were better moved to stdout or tmpfs anyway.