DEV Community

Cover image for Hardening Docker Containers: The Security Habits That Actually Matter
James Joyner
James Joyner

Posted on

Hardening Docker Containers: The Security Habits That Actually Matter

Most of what people call "container security" is a short list of defaults you can set in an afternoon. The exotic tooling — admission controllers, runtime sensors, policy engines — matters at scale, but it's the polish, not the foundation. What actually moves the needle is a handful of flags and one or two Dockerfile lines, ranked here by leverage: highest-impact, lowest-effort first.

I'm going to skip the compliance framing. None of this is about passing an audit. It's about making the difference between "an attacker got code execution in the container" and "an attacker got code execution on the host" as wide as I can with defaults.

Don't run as root

This is the single highest-leverage change, so it goes first. By default a process in a container runs as UID 0. It's namespaced root, not host root — but it's still root inside, and the day someone chains a container escape (a kernel bug, a misconfigured mount, a runc CVE) with root-in-container, that's the bad day. Root inside plus an escape is root on the box. A non-root UID turns the same escape into a much smaller problem.

Set it in the Dockerfile. Create a real user, don't just USER 1000 on top of a root-owned filesystem:

FROM node:20-slim

# Create an unprivileged user and group
RUN groupadd --gid 10001 app \
 && useradd --uid 10001 --gid app --home-dir /app --no-create-home app

WORKDIR /app
COPY --chown=app:app . .
RUN npm ci --omit=dev

USER app
EXPOSE 8080
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Two things people trip on. First, order matters: anything that needs to write to the image (installing packages, chowning files) has to happen before the USER line, because after it you're unprivileged. Second, listening on a port below 1024 as non-root fails unless you grant a capability — more on that in a second.

If you can't rebuild the image (third-party base you don't control), force it at runtime:

docker run --user 10001:10001 REDACTED-image:tag
Enter fullscreen mode Exit fullscreen mode

That won't fix files inside the image owned by root that the app expects to write, which is exactly the kind of thing you find by testing. But a numeric UID with no matching entry in the container's /etc/passwd is fine for most apps and is a clean default.

Drop capabilities, add back only what you need

Even non-root containers get a default set of Linux capabilities — roughly fourteen of them, including things like CHOWN, SETUID, MKNOD, NET_RAW. Most applications use approximately none of these. The default set is far broader than a typical web service needs, and every capability you don't need is attack surface you're carrying for free.

Drop everything, then add back the specific ones the app actually uses:

docker run \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  REDACTED-image:tag
Enter fullscreen mode Exit fullscreen mode

NET_BIND_SERVICE is the common one — it lets a non-root process bind a port below 1024, so you can run as UID 10001 and still listen on 80 or 443. If your app does something lower-level (raw sockets, for instance) you'll discover the missing capability as a permission error and add it back deliberately.

The honest tradeoff: --cap-drop ALL needs testing. Some images shell out to tools that quietly expect a capability, and you won't know until an unusual code path runs. Test the real workload, not just startup.

Read-only root filesystem

If an attacker gets code execution, a writable filesystem lets them drop tools, modify binaries, or persist. Make the root filesystem read-only and the whole class of "write a webshell to disk" goes away:

docker run \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  REDACTED-image:tag
Enter fullscreen mode Exit fullscreen mode

Almost every app needs some writable path — usually /tmp, sometimes a cache or a PID directory. The --tmpfs mount gives you a small, in-memory writable spot without making the whole filesystem writable, and noexec means nothing dropped there can be run.

How do I find what actually needs to be writable? Run it read-only and watch it fail. The app will throw EROFS / "read-only file system" errors pointing at exact paths. Add a tmpfs or a named volume for each real one and re-test. It's iterative, and yes, some apps write all over the place and fight you — those you either fix or grant a narrow volume. But most well-behaved services need /tmp and nothing else.

no-new-privileges

Cheap, no downside I've ever hit, so it's always on:

docker run --security-opt no-new-privileges REDACTED-image:tag
Enter fullscreen mode Exit fullscreen mode

This sets the kernel flag that stops a process from gaining privileges via setuid binaries. If there's a setuid-root binary lurking in the image, this prevents it from being used to escalate. Combined with running as non-root, it closes the "call sudo, become root" path. There's no reason not to set it.

Never --privileged (and mind the Docker socket)

--privileged is not "a bit more access." It disables most of the isolation that makes a container a container: it grants all capabilities, drops the seccomp and AppArmor profiles, and gives access to host devices. A privileged container is, for practical purposes, running on the host. If you've reached for it to make one thing work, you almost certainly wanted a single --cap-add or a specific --device instead.

The sharper edge of the same knife is mounting the Docker socket:

# Do not do this unless you fully mean it
-v /var/run/docker.sock:/var/run/docker.sock
Enter fullscreen mode Exit fullscreen mode

Anything that can talk to docker.sock can start a new container mounting the host's root filesystem, and that is root on the host — full stop. CI runners and "container that manages containers" tooling do this constantly, and it's the footgun I see most often. If a workload needs it, treat that workload as if it already has host root, because effectively it does.

Secrets: not in ENV, not in layers

A sibling article covers image slimming in depth, so I'll keep this tight, but it belongs on the list. Two rules.

Don't put secrets in ENV — they're visible to anything that can run docker inspect and to every child process. And don't COPY a secret file into the image expecting a later RUN rm to erase it; the secret persists in the earlier layer forever, and anyone with the image can pull it back out.

For build-time secrets, use BuildKit's secret mount. The secret is available during that one RUN and never lands in a layer:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
Enter fullscreen mode Exit fullscreen mode
docker build --secret id=npm_token,env=NPM_TOKEN .
Enter fullscreen mode Exit fullscreen mode

For runtime secrets, inject them as environment or mounted files from your orchestrator or a secrets manager at run time — not baked into the image. The image should be safe to push to a registry with nothing sensitive inside it.

Pin base images by digest, and scan them

Here's the unglamorous truth: patching base images is most of your real CVE exposure. Your application code is a small target; the OS packages and language runtime underneath it are where the published, exploitable CVEs actually accumulate.

Pin the base by digest so the build is reproducible and can't drift under you:

FROM node:20-slim@sha256:REDACTEDdigest0000000000000000000000000000000000000000000000000000000000
Enter fullscreen mode Exit fullscreen mode

A tag like node:20-slim moves; a digest doesn't. Then scan on a schedule and treat a bumped digest as routine maintenance, not an event:

docker scout cves REDACTED-image:tag
# or
trivy image REDACTED-image:tag
Enter fullscreen mode Exit fullscreen mode

Pinning without scanning just means you've frozen your vulnerabilities in place, so the two go together: pin for reproducibility, scan to know when to move the pin.

Limit the blast radius

Resource limits aren't confidentiality controls, but they cap what a compromised or buggy container can do to its neighbors — a fork bomb or memory balloon shouldn't take out the host:

docker run \
  --memory 512m \
  --pids-limit 256 \
  REDACTED-image:tag
Enter fullscreen mode Exit fullscreen mode

--pids-limit in particular is a cheap defense against fork bombs, and --memory stops one container from starving everything else on the node. Noisy-neighbor insurance, basically.

Putting it together

Here's a single docker run with the high-leverage flags stacked:

docker run \
  --user 10001:10001 \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --security-opt no-new-privileges \
  --memory 512m \
  --pids-limit 256 \
  REDACTED-image:tag
Enter fullscreen mode Exit fullscreen mode

And the same thing in Compose, which is where most of this ends up living anyway:

services:
  api:
    image: REDACTED-image:tag
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    mem_limit: 512m
    pids_limit: 256
Enter fullscreen mode Exit fullscreen mode

Start with these on your least-critical service, watch it run, and fix what breaks before you roll it out wider. Read-only and cap-drop are the two that need testing; the rest are close to free.

One habit worth building alongside this: before an image ships, run the Dockerfile through the free Dockerfile validator — it flags secrets in ENV, a missing USER, and unpinned base images, which are exactly the mistakes that survive code review because they look normal.

Wrapping up

The durable takeaway, in priority order:

  1. Run as non-rootUSER in the Dockerfile, or --user at runtime. Highest leverage, lowest cost.
  2. --cap-drop ALL, add back only what the app needs (usually just NET_BIND_SERVICE).
  3. --read-only root filesystem plus a small --tmpfs for the paths that genuinely need writing.
  4. --security-opt no-new-privileges — always on, no downside.
  5. Never --privileged, and never mount docker.sock unless that workload already effectively owns the host.
  6. Keep secrets out of ENV and out of layers — BuildKit --mount=type=secret at build, injected at runtime.
  7. Pin base images by digest and scan them — patching the base is most of your real CVE exposure.
  8. Set --memory and --pids-limit to cap the blast radius.

None of this is exotic. It's a short list of defaults, and most of the security you'll ever get from a container comes from just turning them on.

Top comments (0)