DevgainsDevgainsDevgains
All articles

Stop Writing Dockerfiles Like It's 2017

·5 min read

Open a random repository and you will probably find a Dockerfile that was copied from a Stack Overflow answer half a decade ago. It runs everything as root, installs build tools that ship to production, busts the layer cache on every commit, and weighs in at 1.2 GB for an app that is essentially a single binary. It works, which is exactly why nobody touches it.

The container ecosystem has moved on. BuildKit is the default builder, the OCI image and runtime specs are stable, and the patterns that made sense when docker build was a linear shell script are now actively costing you build minutes and CVEs. This article is a tour of the habits worth dropping and what to do instead.

Order your layers by how often they change

The single biggest waste in old Dockerfiles is cache invalidation. Docker builds images as a stack of layers, and a layer is only reused if every instruction before it is unchanged. The classic mistake is copying your entire source tree before installing dependencies:

# Slow: any source change re-runs the install
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]

Every time you touch a single line of application code, COPY . . produces a new layer, which invalidates npm ci and forces a full reinstall. Dependencies change far less often than code, so copy the manifest first and let the install layer cache:

FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]

Now a code change only invalidates the final COPY. The dependency layer is reused until package-lock.json actually changes. This one reordering routinely turns a 90-second rebuild into a 5-second one. The same principle applies to pip install -r requirements.txt, go mod download, and bundle install.

Pin your base images, ideally by digest

FROM node:latest is a time bomb. The tag you build against today is not guaranteed to be the same image next week, which means your builds are not reproducible and a silent base update can break production. At minimum, pin to a specific minor version. Better, pin to a content digest so you get a byte-for-byte identical base every time:

FROM node:20.11-slim@sha256:abc123...

Digests are immutable by definition in the OCI spec, so a pinned digest cannot be moved out from under you. Pair this with a tool like Dependabot or Renovate to bump the digest deliberately, with a pull request and a CI run, rather than by accident on the next deploy.

Stop running as root

By default, processes inside a container run as UID 0. If an attacker escapes your application, root in the container is a much stronger starting position than an unprivileged user. The fix is two lines:

RUN groupadd --gid 1001 app && useradd --uid 1001 --gid app app
USER app

Running as a non-root user is also a hard requirement in many hardened Kubernetes setups, where a runAsNonRoot: true security context will refuse to start a container whose image defaults to UID 0. Bake the user into the image and you stay portable across clusters.

The official Docker build documentation covers the USER instruction and the interaction with file ownership during COPY. Remember to set ownership on any directories your app writes to, since a non-root user cannot write to root-owned paths.

Use a .dockerignore like you mean it

The build context is everything in the directory you point docker build at, and it all gets shipped to the daemon before the build even starts. Without a .dockerignore, you are uploading .git, node_modules, local .env files, and build artifacts on every single build. That is slow, and copying a stray .env into an image is a genuine secret-leak vector.

# .dockerignore
.git
node_modules
*.log
.env
dist
coverage

A tight ignore file shrinks the context, speeds up the upload, and reduces the chance that COPY . . grabs something it should not. Treat it as a security boundary, not just a performance tweak.

Lean on BuildKit features you are probably not using

BuildKit has been the default builder since Docker 23, and it unlocks capabilities the old builder never had. Two are worth adopting immediately.

Cache mounts let a package manager keep its download cache between builds without baking it into a layer:

# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
COPY . .

The --mount=type=cache directive keeps pip's wheel cache on the build host, so even a clean rebuild of the dependency layer reuses downloaded wheels. Secret mounts solve the other perennial problem: passing a token to a build step without leaving it in the image history.

docker build --secret id=npmrc,src=$HOME/.npmrc .
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

The secret is available only during that one RUN and never lands in a layer. Compare this to the old pattern of ARG NPM_TOKEN, which writes the token straight into the image metadata where anyone with the image can read it. The Docker documentation has the full reference for build mounts and the # syntax directive that enables them.

Each RUN produces a layer, so chaining a package install and its cleanup into one instruction keeps the cache directory out of the final image:

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

If the cleanup were a separate RUN, the apt lists would already be committed in the earlier layer and the rm would only mask them, not remove them. That said, do not collapse everything into one giant RUN just to save layers. Layers are cheap and they are your cache boundaries. Group instructions that logically belong together and split the ones that change at different rates.

Takeaways

  • Order instructions from least to most frequently changed so the dependency install layer survives code edits.
  • Pin base images by digest for reproducible builds, and automate deliberate bumps instead of riding latest.
  • Add a non-root USER to every image; it is both a security win and a Kubernetes compatibility requirement.
  • Maintain a real .dockerignore to shrink the build context and avoid leaking secrets through COPY.
  • Adopt BuildKit cache and secret mounts to speed up rebuilds and keep tokens out of image history.
  • Combine install-and-cleanup steps into single RUN instructions, but keep layer boundaries that match your cache needs.
5 min read

Read next