Least Privilege and Resource Limits: Hardening a Container
Containers run as root by default. Drop privileges, lock the filesystem, cap resources — and understand why the Docker socket is the line reviewers watch for.
The Default Is Root
Run this and see for yourself:
docker run --rm alpine id
# uid=0(root) gid=0(root) groups=0(root),...Unless an image says otherwise, your application runs as root inside the container. And without user namespace remapping, that UID 0 is the host's UID 0. Container isolation stands between them — but isolation is a boundary, and boundaries occasionally fail. Running as root means any bug that gets code execution starts from the most powerful position available.
Nothing about running as root is necessary for a typical web application. It's just the default nobody changed.
Drop the Privileges You Don't Need
Hardening is layered. Each of these is independently useful.
Run as a non-root user
FROM node:22-slim
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]Many official images ship a suitable unprivileged user already — node in the Node images, nginx in nginx. Otherwise create one:
RUN groupadd -r app && useradd -r -g app -u 10001 app
USER 10001Prefer a numeric UID in USER. Orchestrators can enforce "must not run as root", and they check the numeric ID — a username they can't resolve at admission time may not satisfy that check. Using USER 10001 makes the guarantee explicit.
Put USER after the instructions that need to write during the build, but make sure the application's runtime directories are owned by that user, or you'll get permission errors at startup.
Make the root filesystem read-only
Most applications never need to write to their own filesystem. Take the ability away:
docker run --read-only --tmpfs /tmp:size=64m myappAnything genuinely needing a writable path gets a --tmpfs mount (memory, discarded on stop) or a volume. This blocks a whole class of attack that depends on writing a payload to disk and executing it.
Drop Linux capabilities
Root inside a container isn't full root — Docker already drops many capabilities. But the default set is still broader than a typical application needs. Drop everything and add back only what's required:
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myappNET_BIND_SERVICE allows binding ports below 1024. Most applications need nothing at all — and if yours listens on 8080 and sits behind a proxy, --cap-drop ALL on its own is often enough.
Prevent privilege escalation
docker run --security-opt no-new-privileges myappThis stops a process gaining privileges it didn't start with, notably through setuid binaries. There is rarely a reason not to set it.
Put together, in Compose:
services:
api:
image: myapp:1.4.0
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:size=64m
cap_drop: [ALL]
security_opt:
- no-new-privileges:trueTwo flags that undo all of the above. --privileged disables essentially every isolation mechanism and grants access to host devices — it is almost never genuinely required, and should be treated as an alarm in review. Mounting /var/run/docker.sock gives the container control of the host daemon, which as phase 1 established is root-equivalent access to the machine. Neither is forbidden, but both are host-trust decisions rather than container configuration.
Check yourself
A container runs as root with the default capability set. An attacker achieves remote code execution through an application vulnerability. How much does running as a non-root user with --cap-drop ALL change the situation?
Resource Limits
Without limits, one container can consume all the memory or CPU on a host and take down every other container with it. The cgroups from phase 1 are the enforcement mechanism; these flags are how you configure them.
docker run \
--memory=512m \
--memory-reservation=256m \
--cpus=1.5 \
--pids-limit=200 \
myapp| Flag | What it does |
|---|---|
--memory | Hard cap. Exceed it and the container is OOM-killed |
--memory-reservation | Soft target the kernel tries to hold you to under pressure |
--cpus | CPU quota — 1.5 means one and a half cores' worth |
--pids-limit | Cap on process count; contains fork bombs and runaway spawning |
The two failure modes are completely different
This is the part worth internalising, because the symptoms share nothing:
- Memory limit exceeded → the container is killed. Exit
137, an obvious event,.State.OOMKilledset to true. Loud and unambiguous. - CPU limit reached → the container is throttled. Nothing is killed. No log line, no event. The application just gets less CPU time, so latency climbs and requests time out.
Nearly every "it's slow but nothing's wrong" investigation ends at a CPU limit, precisely because throttling produces no error to search for.
docker stats # live usage against limits
docker inspect --format '{{.State.OOMKilled}}' myapp # was it an OOM kill?
docker inspect --format '{{.HostConfig.Memory}}' myapp # the limit, in bytesRuntimes that size themselves from available memory need care. Older JVMs read the host's total memory rather than the cgroup limit, size a heap far larger than the container allows, and get OOM-killed under load — while the JVM believes it's well within budget. Modern JVMs are container-aware, but setting limits explicitly (-XX:MaxRAMPercentage, or an explicit -Xmx) removes the guesswork. Node, Python and Go have their own variants of this problem.
Pick limits from evidence
A limit invented at the keyboard is either wasteful or a future incident. Measure instead:
- Run the container under realistic load.
- Watch
docker statsfor steady-state and peak memory. - Set the limit above observed peak with headroom — commonly 1.5–2×, more if traffic is spiky.
- Re-check after a release that changes memory behaviour.
Check yourself
Two containers are misbehaving. Container A restarts periodically with exit 137. Container B never restarts but its p99 latency has tripled. What's the likely cause of each?
A Hardened Compose Service
Everything from this guide, applied:
services:
api:
image: myregistry/api:1.4.0
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:size=64m
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
start_period: 40s
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"Non-root, read-only, no capabilities, no privilege escalation, bounded resources, a real health check, and a port bound to loopback rather than every interface.
Apply these one at a time and test between each. read_only: true in particular tends to surface an application's undeclared assumptions about writable paths — a cache directory, a PID file, a temporary upload location. Those are worth discovering deliberately now rather than during an incident.
What's Next
Your container now runs with minimal privilege. The next guide covers the thing it still needs and most often mishandles: credentials — where they leak at build time, where they leak at runtime, and how to supply them without either.