DEV Community

Openrouting for Openrouting

Posted on Originally published at openrouting.si

Dockerfile security mistakes companies keep making

Key takeaways

  • Set a non-root numeric USER; container root is one bug from host root.
  • Deleting a secret in a later layer doesn't remove it; use BuildKit secret mounts.
  • Pin base images by version and digest, and install from lockfiles.
  • Never pipe curl into a shell; verify checksums.
  • Update, install and clean in one RUN, and rebuild on a schedule.

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"]
Enter fullscreen mode Exit fullscreen mode

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.

(Diagram: see the original post.)
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
Enter fullscreen mode Exit fullscreen mode
docker build --secret id=npmrc,src=$HOME/.npmrc .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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)"]
Enter fullscreen mode Exit fullscreen mode

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 flags hardcoded secrets in the files it scans. 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.

To check the Dockerfile itself, paste it into the free Dockerfile explainer. It explains each instruction, flags the build-time mistakes above such as running as root and secrets baked into the image, and shows the stages and layers, also in your browser.

Questions

Should a Docker container run as root?
No. Set USER to a non-root numeric UID. Without user namespaces, container root is the same UID as host root, so a Docker socket mount, host path mount, extra capability or runtime bug can put it one step from root on the node.

Does deleting a secret in a later Dockerfile layer remove it?
No. A later RUN rm only adds a layer that hides the file; the bytes stay in the earlier layer, and anyone who can pull the image can extract it. Rotate any secret that was in a pushed image.

How do I pass a secret to a Docker build safely?
Use a BuildKit secret mount (RUN --mount=type=secret), which makes the secret available to a single RUN step without writing it to a layer. Don't pass secrets through COPY, ENV or ARG.

Why pin a Docker base image by digest?
A tag like node:latest can pull a different image on every build, so what you tested isn't what you ship. A digest is immutable, and Renovate or Dependabot can open reviewed pull requests that bump it.


Originally published on openrouting.si. Want to check your own Dockerfile? The free Dockerfile explainer flags these mistakes right in your browser; nothing is uploaded.

Top comments (0)