← Recipes

阅读中文版本

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.

~25 mindeploydockersecurity

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 -3

Expected 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.