Skip to main content
DevOps Insightsguides & answers

Security · advanced

Securing Docker Containers, in Order of What It Buys You

Short answer

Hardening lists imply their items matter equally. They do not. These are ordered by what each one actually buys, with the threat it defends against named, and the first three carry most of the value. All of it limits what a compromised container can do; none of it stops the compromise itself, which is a different piece of work.

N
Neeraj Jha
Updated 10 min read169 views
On this page · 14 sections
A five-step container hardening list beside a docker run command that drops capabilities and runs read-only.

Container hardening advice usually arrives as a list of ten equally-weighted items, which quietly implies they matter equally. They do not. Some of these close a real path an attacker would take; others are worth doing and will not be the thing that saves you.

So this is the same checklist, ordered by what it actually buys, with the threat each item defends against stated. If you only do the first three, you have most of the value.

A note on scope before the list: everything here is about limiting what a compromised container can do. None of it stops your application being compromised in the first place — that is a different job, mostly about dependencies and input handling.


Who this is for

You have a hardening checklist and no sense of which items on it matter. You do not need to do all ten — the first three carry most of the value, and none of them stops the compromise itself.

1. Do not run as root

Defends against: a container escape becoming host root, and an attacker who gets code execution being able to write anywhere in the image.

By default a container's process runs as UID 0. That is the same UID 0 as the host. Container isolation is what stands between those two facts, and container isolation has had escapes.

dockerfile
FROM node:24-alpine

RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app . .
RUN npm ci --omit=dev

USER app
CMD ["node", "server.js"]

Check it actually took effect, because a later USER root in a derived image silently undoes it:

bash
docker exec <container> id
# uid=100(app) gid=101(app) — not uid=0(root)

In Kubernetes, enforce it rather than trusting the image:

yaml
securityContext:
  runAsNonRoot: true          # refuses to start if the image would run as root
  runAsUser: 10001
  allowPrivilegeEscalation: false

runAsNonRoot: true is the valuable line. It turns "we intended to run as a user" into something the cluster checks.

2. Keep secrets out of the image

Defends against: an incident that needs no escape at all — a credential found in a layer, an environment variable, or a build argument.

Anyone who can pull your image can read every layer of it. A secret added in one layer and deleted in the next is still in the earlier layer. Build arguments are visible in the image history.

bash
# What an attacker with the image does first:
docker history --no-trunc <image>
docker save <image> | tar -x -O | strings | grep -iE 'password|secret|token|BEGIN.*PRIVATE KEY'

Inject secrets at runtime, from the orchestrator's secret store or a secrets manager. The underlying principle is worth reading properly: secrets do not belong in Git — and an image is a worse place than Git, because it is distributed more widely and rotated less often.

If a secret has already been committed, the block is not the fix — pushing is declined but the credential is still compromised, and rotation is the step that matters.

3. Use a minimal base image, and update it

Defends against: a large inventory of packages you never invoke but must still patch, and the shell an attacker uses after landing.

BaseRough size (compressed)What is in it
ubuntu:24.04~30 MBA general-purpose userland
node:24~410 MBFull Debian plus toolchain
node:24-alpine~60 MBmusl, busybox, few packages
gcr.io/distroless/nodejs24-debian13~55 MBNo shell, no package manager

The size is not the point — the package count is. Fewer packages means fewer advisories to triage, which is what actually consumes time. Distroless goes further by removing the shell, which makes hands-on-keyboard exploitation meaningfully harder.

The part people skip: rebuilding. A minimal base image from eight months ago is not minimal risk. Rebuild on a schedule, not only when your code changes.

Trade-off worth knowing: distroless makes debugging harder, because there is no shell to exec into, and Alpine's busybox shell and tools are a thin kit next to a full distribution. Ephemeral debug containers solve that in Kubernetes, and it is worth learning before you need it at 3am rather than after.

4. Drop capabilities and privilege escalation

Defends against: a compromised process using kernel capabilities it never needed — raw sockets, bypassing file permission checks, changing ownership.

Containers get a default capability set that is generous for most applications.

yaml
securityContext:
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]   # only if you truly bind below 1024

Better still, do not bind below 1024 — listen on 8080 and let the Service map the port. Then you need no capabilities at all.

And never privileged: true for an application workload. It disables most of the isolation you are relying on.

5. Read-only root filesystem

Defends against: an attacker writing a tool, a persistence mechanism, or a modified binary into the container.

yaml
securityContext:
  readOnlyRootFilesystem: true
volumeMounts:
  - name: tmp
    mountPath: /tmp
volumes:
  - name: tmp
    emptyDir: {}

Most applications need somewhere writable, so mount a small emptyDir for it rather than abandoning the setting. The usual blockers are temp files, caches and PID files — all of which can live in a mounted volume.

This one is genuinely effective and genuinely fiddly. Expect a round of "what does this thing write and where".

6. Set resource limits

Defends against: one compromised or misbehaving container taking the node down with it — a denial of service against everything else you run.

yaml
resources:
  requests:   { cpu: "100m", memory: "128Mi" }
  limits:     { cpu: "500m", memory: "512Mi" }

Security and reliability overlap here. The same limit that contains a crypto-miner also contains a memory leak. Be aware of what exceeding a memory limit does — the container is killed, which is OOMKilled — and that requests and limits do different jobs.

7. Scan images, and triage the output

Defends against: shipping a known-vulnerable dependency, if you act on what the scanner says.

Scanning is the item most likely to be done badly. A scan that produces four hundred findings nobody triages is not security work — it is a dashboard. The value is in the triage: is the vulnerable code reachable, is there a path from untrusted input, does a fix exist.

That triage is a discipline in itself, covered in turning 312 findings into four.

Run it in the pipeline, and be deliberate about what blocks a build versus what warns. A gate that fires on everything gets disabled.

8. Verify what you run

Defends against: running an image that is not the one your pipeline built — a registry compromise, a mutable tag overwritten, a typosquatted base image.

Sign images at build and verify at admission. This is the item that has moved fastest recently and it is genuinely worth adopting.

Two cautions from practice. Pin by digest rather than tag, because a tag can be moved under you. And when verification fails, read the whole message: cosign says no signatures found for "unsigned" and no matching signatures: plus a reason for "signed by someone else", and skimming past that difference is precisely why the failure so often gets "fixed" by removing the check. Never do that. Find out which of the two you have, then restore it.

9. Segment the network

Defends against: lateral movement — a compromised frontend reaching a database it never needed to reach.

By default, most container networks let everything talk to everything. Default-deny, then allow what is required.

The Compose version is a second network marked internal: true with no published ports on the database. In Kubernetes it is NetworkPolicies, which require a CNI that enforces them — worth checking, because applying a policy to a cluster that ignores it gives you a document rather than a control.

10. Log what happened

Defends against: nothing, on its own. This is the one that only pays off after an incident.

Container logs, audit logs and image provenance are what let you answer "what did it do" and "how did it get in". They do not prevent anything, which is why they belong last on a list ordered by prevention — and why they are also the thing you most regret not having.


Where runtime secrets end up

Added 31 October 2026. Item 2 says to inject secrets at runtime from the orchestrator's secret store. On Kubernetes, check what that store actually protects, because the Kubernetes documentation is explicit about the defaults:

  • Secrets are stored unencrypted in etcd unless encryption at rest is configured, and nothing on the Secret object says whether it is.
  • The base64 in a Secret's data field is a format, not a lock: kubectl get secret NAME -o jsonpath='{.data.KEY}' | base64 --decode reverses it.
  • Anyone who can create a Pod in a namespace can mount any Secret in it and print it, so deploy rights are, in practice, read rights.

What a Secret does give you is narrower handling on nodes: it is sent only to nodes that run a Pod needing it, and a mounted Secret is kept in memory-backed storage rather than written to the node's disk. To make it confidential, the documentation's minimum is encryption at rest, least-privilege RBAC on Secrets and on Pod creation, and possibly an external secret store. The detail: ConfigMap vs Secret.

What this list does not do

Worth being honest about, because hardening can produce a false sense of completeness.

  • It does not secure your application. An SQL injection in your code is unaffected by every item above.
  • It does not replace patching. A minimal, non-root, read-only container running a vulnerable version of your framework is still vulnerable.
  • It is not a compliance checklist. Passing all ten satisfies no standard by itself.
  • Some of it is nearly free and some is not. Items 1 through 4 are configuration. Item 5 will take an afternoon of finding out what your application writes. Item 7 is ongoing work forever.

If you do three things

Run as a non-root user with runAsNonRoot: true. Keep secrets out of the image and rotate anything that ever went in. Use a minimal base and rebuild it on a schedule.

Those three close the paths most likely to be taken, and they are the ones that stay closed without continuous effort.

Mentioned in this article

Tagged with

Enjoyed this article?

Get more DevOps insights delivered to your inbox.

Get new posts by email

Subscribe to get an email when a new blog post is published. Skip anytime.

No spam, unsubscribe anytime.

N

Written by

Neeraj Jha

Platform administrator and lead writer.

View all posts →

Discussion

0 comments

Sign in to join the conversation.

Be the first to comment

Start a conversation about this post

Share: