05-production-hardening

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.

September 13, 2026
dockersecuritynon-rootcapabilitiesread-onlyresource-limitsoom

The Default Is Root

Run this and see for yourself:

bash
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

dockerfile
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:

dockerfile
RUN groupadd -r app && useradd -r -g app -u 10001 app
USER 10001

Prefer 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:

bash
docker run --read-only --tmpfs /tmp:size=64m myapp

Anything 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:

bash
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myapp

NET_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

bash
docker run --security-opt no-new-privileges myapp

This 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:

yaml
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:true
🚨

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

bash
docker run \
  --memory=512m \
  --memory-reservation=256m \
  --cpus=1.5 \
  --pids-limit=200 \
  myapp
FlagWhat it does
--memoryHard cap. Exceed it and the container is OOM-killed
--memory-reservationSoft target the kernel tries to hold you to under pressure
--cpusCPU quota — 1.5 means one and a half cores' worth
--pids-limitCap 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.OOMKilled set 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.

bash
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 bytes
⚠️

Runtimes 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:

  1. Run the container under realistic load.
  2. Watch docker stats for steady-state and peak memory.
  3. Set the limit above observed peak with headroom — commonly 1.5–2×, more if traffic is spiky.
  4. 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:

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