DEV Community

DatanestDigital
DatanestDigital

Posted on

Hardening Docker Containers: A Practical 2026 Security Checklist

A container is not a security boundary by default. Out of the box, the typical image runs as root, ships a full distro's worth of attack surface, can write anywhere, and keeps every Linux capability it was born with. None of that is required to run your app — it's just the path of least resistance, and attackers love it.

This is the checklist I run before anything goes to production. Every item is concrete, most are one or two lines, and together they turn a default container into a genuinely hard target. Copy the snippets, adapt, ship.

1. Don't run as root

If your process is root inside the container and an attacker escapes the process, they're root in the container — and root in the container is one misconfiguration away from root on the host. Create and switch to an unprivileged user.

RUN addgroup --system app && adduser --system --ingroup app app
USER app
Enter fullscreen mode Exit fullscreen mode

At runtime, enforce it so a bad image can't override the intent:

# docker-compose / k8s securityContext
user: "10001:10001"
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
Enter fullscreen mode Exit fullscreen mode

no-new-privileges blocks setuid binaries from escalating — a cheap, high-value flag.

2. Start from the smallest base you can

Every package in your base image is attack surface and another CVE to patch. python:3.12 is ~1 GB; python:3.12-slim is a fraction of that; distroless or Alpine smaller still. Fewer files means fewer vulnerabilities and a smaller blast radius.

# from this...
FROM python:3.12
# ...to this
FROM python:3.12-slim
# ...or, for compiled/static apps, distroless with no shell at all
FROM gcr.io/distroless/static-debian12
Enter fullscreen mode Exit fullscreen mode

A distroless image has no shell and no package manager, so even after a breakout the attacker has almost nothing to work with.

3. Use multi-stage builds to leave the toolchain behind

Build tools, compilers, and dev dependencies should never reach production. Build in one stage, copy only the artifact into a clean final stage.

FROM python:3.12 AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt

FROM python:3.12-slim
COPY --from=build /install /usr/local
COPY . /app
USER app
Enter fullscreen mode Exit fullscreen mode

The final image carries your app and its runtime deps — and nothing you only needed to build it.

4. Make the filesystem read-only

A process that can't write can't drop a webshell, tamper with binaries, or persist. Run the root filesystem read-only and mount explicit, narrow tmpfs volumes for the few paths that genuinely need writes.

read_only: true
tmpfs:
  - /tmp
  - /run
Enter fullscreen mode Exit fullscreen mode

If your app refuses to start read-only, that's a useful finding: it's writing somewhere it shouldn't.

5. Drop every capability, then add back only what you need

Containers inherit a default set of Linux capabilities most apps never use. Drop them all and re-add the rare exceptions explicitly (a web app almost never needs any).

cap_drop: ["ALL"]
# cap_add: ["NET_BIND_SERVICE"]   # only if you must bind to a port < 1024
Enter fullscreen mode Exit fullscreen mode

Better: don't bind to a privileged port at all — listen on 8080 and let the load balancer map 443.

6. Keep secrets out of the image and the environment

ENV API_KEY=... and COPY .env . bake secrets into image layers forever — anyone who pulls the image can docker history them out. Inject secrets at runtime via your orchestrator's secret store or mounted files, and add .env, keys, and .git to .dockerignore so they never enter the build context.

# .dockerignore
.env
*.pem
.git
**/secrets*
Enter fullscreen mode Exit fullscreen mode

Environment variables leak through crash dumps, child processes, and /proc; prefer mounted secret files with tight permissions where you can.

7. Pin versions and scan every image

FROM node:latest means your build is non-reproducible and silently pulls tomorrow's unpatched surprise. Pin to a digest, and run a scanner in CI that fails the build on high/critical CVEs.

FROM python:3.12-slim@sha256:<digest>
Enter fullscreen mode Exit fullscreen mode
# in CI — fail on serious findings
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:sha-1234
Enter fullscreen mode Exit fullscreen mode

Scanning once is theater; scanning on every build is control. Rebuild regularly so base-image patches actually reach production.

8. Add a HEALTHCHECK and handle signals

A container that can't report health hides failures; a process that ignores SIGTERM gets SIGKILLed on every deploy, dropping in-flight work. Both are reliability and security concerns — zombie or wedged containers are where bad things hide.

HEALTHCHECK --interval=30s --timeout=3s \
  CMD curl -fsS http://localhost:8080/healthz || exit 1
Enter fullscreen mode Exit fullscreen mode

Make sure your app traps SIGTERM and shuts down gracefully (or run it under an init like tini to reap zombies).

9. Constrain resources

Set memory and CPU limits so a single compromised or buggy container can't starve its neighbors or the host — a trivial denial-of-service otherwise.

deploy:
  resources:
    limits:
      memory: 512M
      cpus: "0.5"
Enter fullscreen mode Exit fullscreen mode

The quick audit

Before you ship, ask: Is it non-root? Is the base minimal and pinned? Is the filesystem read-only? Are capabilities dropped? Are secrets out of the image? Does CI scan and fail on critical CVEs? Six yeses and you've eliminated the overwhelming majority of real-world container attacks.

If you want this enforced as ready-to-use templates rather than a checklist you re-implement per project, the Container Security Toolkit ships hardened Dockerfiles, secure securityContext presets, and a CI scanning pipeline you can drop into an existing repo.

Bottom line

Container hardening isn't one big effort — it's a stack of small, boring defaults that each remove an attacker's option. Non-root, minimal, read-only, capability-dropped, secrets-externalized, and continuously scanned. Set them once, bake them into your base templates, and every service you ship inherits the security instead of relitigating it.

Top comments (0)