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'
runAsNonRootcan only verify a numeric user; with a name it refuses to start the pod. - Leave the code owned by root.
COPYcreates 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
chownjust 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
COPYof an.envfile, 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, anddocker inspectprints it. - Passing it as a build argument. An
ARG NPM_TOKENused by aRUNstep 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.
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 cidocker 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.jsonA 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 ciwith a lockfile instead ofnpm install, andpip install --require-hashes -r requirements.txtinstead of an unpinnedpip install flask.
5. Piping remote scripts into a shell
RUN curl -sL https://example.com/install.sh | bashThis 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.tgzAlso 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 curlDocker 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 execorkubectl execfor debugging instead. - Making everything world-writable.
chmod -R 777to "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:
USERis set to a non-root, numeric UID- No secrets in
COPY,ENVorARG; build-time secrets use--mount=type=secret - A
.dockerignorekeeps.git,.envand 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;COPYinstead ofADD - A multi-stage build with a minimal final image
apt-get updateandinstallin oneRUN, and images are rebuilt on a schedule- No sudo, no sshd, no
chmod 777 - Exec-form
CMDandENTRYPOINT, and aHEALTHCHECKwherever 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.