Safeguard
Containers

Writing a Secure Kubernetes Dockerfile: A Practical Hardening Guide

The Dockerfile you write decides most of a pod's attack surface before Kubernetes ever schedules it. Here is how to build images that run non-root, stay small, and survive a security review.

Karan Patel
Platform Engineer
5 min read

A secure Kubernetes Dockerfile is one that runs as a non-root user, ships the smallest possible base image, and hardcodes as few secrets and privileges as it can get away with. Get the kubernetes dockerfile right and half of your pod-level security review answers itself, because the cluster can only enforce guardrails on top of what the image already allows.

Kubernetes does not build images; it runs them. So the Dockerfile is where container security actually starts. A dockerfile kubernetes setup that bakes in root, a full OS, and a shell gives an attacker who lands in the container far more to work with than a minimal image ever would.

Start from a minimal, pinned base image

The base image is your largest single lever. A python:3.12 image carries hundreds of packages you will never call; each is a potential CVE your scanner has to track. Prefer slim or distroless variants:

# Instead of: FROM python:3.12
FROM python:3.12-slim@sha256:<digest>

Pin by digest, not just tag. A tag like 3.12-slim is mutable and can point at a different image next week; a digest is immutable and makes your builds reproducible. For compiled languages, distroless images (which contain your app and its runtime but no package manager or shell) shrink the attack surface even further.

Run as a non-root user

By default a container process runs as root. If that process is compromised and a container-escape bug exists, root inside the container is a much shorter path to root on the node. Create and switch to an unprivileged user in the Dockerfile:

FROM node:20-slim
RUN groupadd --gid 10001 app \
 && useradd --uid 10001 --gid app --shell /usr/sbin/nologin app
WORKDIR /app
COPY --chown=app:app . .
RUN npm ci --omit=dev
USER 10001
CMD ["node", "server.js"]

Using a numeric UID (USER 10001) rather than a name matters in Kubernetes, because the runAsNonRoot admission check validates against a numeric ID. A named user without a resolvable UID can fail that check.

Use multi-stage builds to drop build tooling

Compilers, dev headers, and package caches belong in a build stage, not the final image. Multi-stage builds copy only the finished artifact into a clean runtime layer:

FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server

FROM gcr.io/distroless/static:nonroot
COPY --from=build /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

The runtime image here has no shell, no package manager, and no compiler. An attacker who reaches code execution finds almost nothing to pivot with.

Keep secrets out of layers

Never COPY a .env file or ARG a token into an image. Docker layers are cached and distributable; a secret baked into any layer is recoverable with docker history even if a later layer deletes the file. Inject secrets at runtime through Kubernetes Secrets mounted as files or environment variables, or through a secrets manager. If you must use a credential during the build, use BuildKit secret mounts (RUN --mount=type=secret) so the value never lands in a layer.

Let the Pod spec enforce what the image assumes

The Dockerfile sets defaults; the Kubernetes manifest makes them mandatory. Pair a non-root image with a securityContext that refuses to run any other way:

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

readOnlyRootFilesystem: true is the one people forget. If your app needs scratch space, mount an emptyDir at /tmp instead of leaving the whole filesystem writable. Dropping all Linux capabilities and blocking privilege escalation closes the most common in-container escalation paths.

Scan the image, then scan it again in CI

A hardened Dockerfile still inherits CVEs from its base image and its language dependencies. Build a scan into the pipeline so a newly disclosed vulnerability in a pinned layer fails the build rather than reaching a cluster. Image and dependency scanning belongs on every push; an SCA and container scanning tool can inventory both the OS packages in the base layer and the application dependencies you copied in. For teams formalizing this, our notes on wiring security into each SDLC phase cover where the image scan gate fits.

FAQ

Does Kubernetes read anything from my Dockerfile?

No. Kubernetes runs the built image and reads configuration from the Pod spec (securityContext, resource limits, volumes). The Dockerfile influences security only through the image it produces, which is why non-root users and minimal layers have to be set at build time.

Should I use distroless or slim base images?

Distroless gives the smallest attack surface because it has no shell or package manager, which is ideal for compiled languages and production. Slim images keep a package manager, which is more convenient for interpreted languages that need runtime dependencies. Both are far better than a full OS base.

Why pin the base image by digest instead of tag?

Tags are mutable, so node:20-slim can point to a rebuilt image later, breaking reproducibility and silently changing your attack surface. A digest (@sha256:...) is immutable, so every build uses the exact bytes you tested and scanned.

How do I make a container run non-root in Kubernetes?

Create a numeric user in the Dockerfile with USER 10001, then set runAsNonRoot: true and a matching runAsUser in the Pod securityContext. The numeric UID lets the admission check validate that the container is genuinely not running as root.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.