Dockerfile security mistakes companies keep making

$ cat blog/dockerfile-security-mistakes.md
✓ containers
✓ Docker
✓ secrets
✓ supply chain
10 min read
$ 

A Dockerfile is a security boundary written by people who are mostly trying to get the build green. That's why the same problems keep coming back: few of them are exotic vulnerabilities, and most are defaults copied from a tutorial and never revisited. Each one is cheap to fix once you know to look for it.

The security mistakes come first, roughly in order of how much damage they enable. A shorter section on reliability follows, because a container you can't stop or health-check cleanly also makes incidents harder to handle.

1. Running as root

If a Dockerfile never sets USER, the process runs as root (UID 0) inside the container. Container root is not automatically host root, but unless user namespaces are enabled it's the same UID. Add a mounted Docker socket, a host path mount, an extra Linux capability or a container-runtime bug, and root in the container is one step from root on the node. Even without an escape, root lets anyone who gets code execution rewrite your application and install whatever tools they want.

FROM python:3.12-slim
RUN useradd --system --uid 10001 --no-create-home app
WORKDIR /app
COPY . .
USER 10001
CMD ["python", "-m", "app"]

Three details matter here:

  • Use a numeric UID. Kubernetes' runAsNonRoot can only verify a numeric user; with a name it refuses to start the pod.
  • Leave the code owned by root. COPY creates root-owned files by default. The runtime user can read them but can't modify them, so a compromised process can't plant a backdoor in your source.
  • Grant write access narrowly. If the app needs a writable directory, create and chown just that one, e.g. RUN mkdir -p /app/data && chown 10001 /app/data.

2. Baking secrets into the image

There are three common ways a secret ends up inside an image:

  • Copying it in. A COPY of an .env file, an SSH key for a private dependency, or a cloud credentials file.
  • Setting it as an environment variable. ENV API_KEY=... is stored in the image config, and docker inspect prints it.
  • Passing it as a build argument. An ARG NPM_TOKEN used by a RUN step is recorded in the image history (docker history).

The trap is believing a later RUN rm cleans it up. It doesn't. An image is a stack of layers, and deleting a file only adds a new layer that hides it. The bytes are still in the earlier layer.

image layers (top = newest) 4 RUN rm /root/.ssh/id_rsa hides it 3 RUN git clone <private repo> 2 COPY id_rsa /root/.ssh/ key bytes 1 FROM python:3.12-slim docker run ls /root/.ssh → (empty) docker save extract layer 2 → id_rsa, readable
Deleting a file in a later layer only hides it. The running container shows no key, but anyone with pull access can extract layer 2 and read it.

Anyone who can pull the image can extract that layer, and pull access is usually broad: CI runners, every developer, every cluster node, sometimes a public registry by mistake.

Fix: use BuildKit secret mounts. The secret is available to a single RUN step and never written to a layer:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .

Runtime secrets (database passwords, API keys) don't belong in the image at all. Inject them when the container starts, from your orchestrator's secret store or a secrets manager.

If a secret was ever in an image that was pushed, rotate it. Deleting the tag doesn't un-leak it, for the same reason that deleting a secret from a file doesn't remove it from git history.

3. No .dockerignore

COPY . . copies the whole build context, which is everything in the directory unless .dockerignore says otherwise. That usually includes .git (your entire history, including any secret ever committed), local .env files, editor configs, and test fixtures that sometimes hold real data.

Fix: add a .dockerignore. The safest form is an allow-list: exclude everything, then add back what the image needs.

# .dockerignore: start from nothing, add back what the image needs
*
!src/
!package.json
!package-lock.json

A smaller context also makes builds faster, so this one pays for itself.

4. Unpinned base images and dependencies

FROM node:latest (or just FROM node) means every build can pull a different image. What you tested isn't what you ship, and anything that changes upstream flows straight into production with no review.

Fix:

  • Pin the version, and ideally the digest: FROM node:22-bookworm-slim@sha256:<digest>. A digest is immutable; a tag isn't.
  • Let a bot do the updating. Renovate or Dependabot can open pull requests that bump the digest, so you still get security patches, but each change is reviewed and recorded.
  • Prefer trusted publishers. Use Docker Official Images or verified publishers, not a similarly named image from a random account.
  • Pin application dependencies too. Use npm ci with a lockfile instead of npm install, and pip install --require-hashes -r requirements.txt instead of an unpinned pip install flask.

5. Piping remote scripts into a shell

RUN curl -sL https://example.com/install.sh | bash

This runs whatever that server returns at build time, as root, with no record of what it was. If the server, the domain or the CDN is ever compromised, so is every image you build. ADD https://... has a similar problem: it fetches a remote file with nothing checking that it's the one you expect.

Fix: download a pinned version, verify its published checksum, then install.

ARG TOOL_VERSION=1.4.2
ARG TOOL_SHA256=<published sha256>
RUN curl -fsSLo /tmp/tool.tgz "https://example.com/tool-${TOOL_VERSION}.tgz" \
 && echo "${TOOL_SHA256}  /tmp/tool.tgz" | sha256sum -c - \
 && tar -xzf /tmp/tool.tgz -C /usr/local/bin tool \
 && rm /tmp/tool.tgz

Also prefer COPY over ADD for local files. ADD silently extracts tar archives and fetches URLs; COPY does exactly one thing.

6. Shipping the whole toolchain

A single-stage build ships everything it needed to *build*: compilers, package managers, git, curl, a full shell. That's exactly the toolkit an attacker wants after getting code execution. Every extra package is also another source of CVEs that you have to patch.

Fix: use a multi-stage build and keep the final stage minimal.

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

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

The final image contains your binary and almost nothing else, and the nonroot variant runs as an unprivileged user by default. For interpreted languages, start from a -slim image and install only runtime dependencies (--no-install-recommends).

7. Stale packages from a cached apt-get update

# Wrong: the update layer is cached, so later installs use an old package index
RUN apt-get update
RUN apt-get install -y curl

Docker caches each instruction separately. Once apt-get update is cached, later builds install from an old index, which can mean old, vulnerable versions.

Fix: update, install and clean up in a single RUN:

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl \
 && rm -rf /var/lib/apt/lists/*

Also rebuild on a schedule, not only when your code changes. Security patches to the base image reach you only when you rebuild.

8. sudo, SSH and chmod 777

Three habits from managing servers that don't belong in containers:

  • Installing sudo gives your non-root user a way back to root, which undoes mistake #1's fix.
  • Running an SSH server adds a long-lived credential and a listening service. Use docker exec or kubectl exec for debugging instead.
  • Making everything world-writable. chmod -R 777 to "fix" a permission error lets any process rewrite your application. Give ownership of the one directory that needs it instead.

Reliability mistakes that make incidents worse

Shell-form CMD and ENTRYPOINT. CMD python app.py runs under /bin/sh -c, so your process isn't PID 1 and doesn't receive signals. docker stop sends SIGTERM, nothing happens, and after the timeout the process is killed with no graceful shutdown. Use the exec form, CMD ["python", "app.py"]. Add --init (or tini) if your process starts children it doesn't reap.

No HEALTHCHECK. Without one, Docker and Compose can't tell "running" from "working", and depends_on with condition: service_healthy has nothing to wait for. Kubernetes ignores HEALTHCHECK and uses its own probes instead, so define those there.

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]

A quick review checklist

Run through this list before merging any Dockerfile change:

  • USER is set to a non-root, numeric UID
  • No secrets in COPY, ENV or ARG; build-time secrets use --mount=type=secret
  • A .dockerignore keeps .git, .env and keys out of the build context
  • The base image is pinned (version plus digest) and comes from a trusted publisher; dependencies are installed from lockfiles
  • No curl | sh; every download is checksum-verified; COPY instead of ADD
  • A multi-stage build with a minimal final image
  • apt-get update and install in one RUN, and images are rebuilt on a schedule
  • No sudo, no sshd, no chmod 777
  • Exec-form CMD and ENTRYPOINT, and a HEALTHCHECK wherever Docker or Compose runs the container

Much of this can be automated. docker build --check runs Docker's built-in build checks (including one that flags secrets passed through ARG or ENV), linters such as hadolint cover many of the rest, and an image scanner catches known-vulnerable packages.

How OpenRouting helps

OpenRouting's container review agent checks Dockerfiles and Kubernetes manifests for root users, privileged mode, added capabilities and host-level access, and a full-repo scan also looks for secrets committed anywhere in your git history. Each finding comes with a plain-English explanation and a suggested fix.

If you run your images with Docker Compose, paste the file into the free Docker Compose explainer. It flags the run-time version of these mistakes, such as privileged mode, Docker socket mounts and databases published on every interface, right in your browser.

Keep reading