Skip to content

Version Controlling Dockerfiles: Review, Pinning and Reproducibility

Lesson 2 of 8Intermediate12 min readGit for DevOps & Infrastructure · ContainersVerified: Docker build documentation, September 2026

A Dockerfile is about twelve lines long and decides what runs as root in production.

That ratio is why it gets less review than it deserves. It looks like configuration, it is short, and it usually works. It is also the file that determines your base operating system, your installed packages, your runtime user and everything that ends up in a layer — which makes it one of the highest-leverage files in the repository.

With the application it builds, at the repository root, or adjacent to the service in a monorepo.

The reason is coupling. A Dockerfile changes when the application’s runtime requirements change — a new native dependency, a different Node version, an extra system package. Those changes belong in the same commit as the code that needs them, reviewed together, merged together.

The pattern to avoid is a central “docker” repository holding every service’s Dockerfile. It sounds organised. In practice it means two pull requests for one change, an ordering problem between them, and a repository owned by whoever set it up rather than by the teams whose services depend on it.

Naming. Dockerfile for the primary image. Dockerfile.<purpose> where a second genuinely exists — and be sceptical about the second, because multi-stage targets usually serve better.

Most guides frame it as a build-speed optimisation. It is also the thing standing between a developer’s local .env and a published image layer.

Without one, COPY . . copies the entire build context: the .git directory with its full history, node_modules, local environment files, editor configuration, cached credentials.

# Version control
.git
.gitignore
.github
# Dependencies — reinstalled in the image
node_modules
vendor
__pycache__
*.pyc
# Local environment — never in an image
.env
.env.*
!.env.example
*.pem
*.key
.aws
.ssh
# Build output and test artifacts
dist
build
coverage
.pytest_cache
# The build files themselves
Dockerfile*
compose*.yaml
.dockerignore
# Documentation
*.md
docs

Three lines are doing work most people do not notice.

.git — the full history, including any secret ever committed and later removed. A .git directory inside a published image is a disclosure of everything that was ever in the repository.

.env and *.pem — the specific files that cause incidents. The !.env.example re-inclusion is deliberate: deny broadly, allow the safe one back.

Dockerfile* — not a security issue, just noise in the context that invalidates cache.

# syntax=docker/dockerfile:1
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:${NODE_VERSION}-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:${NODE_VERSION}-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Separate deps, build and runtime stages. Dependencies install once and are reused. Build tooling never reaches the runtime image.

ARG NODE_VERSION at the top. One place to change the version, and CI can override it to test against a newer runtime without editing the file.

USER node before CMD. The container does not run as root. Reviewers should look for this line and its absence should be explained.

npm cache clean in the same RUN. Cleaning in a later instruction leaves the cache in the earlier layer — the same layer-permanence property as the secret case above, applied to size instead of security.

# syntax=docker/dockerfile:1 opts into the current BuildKit frontend, which is what makes secret mounts, cache mounts and heredoc syntax available. It resolves to the latest v1 frontend at build time, so a build picks up frontend improvements without a Dockerfile change — one of the few places a floating reference is the conventional choice.

The decision with the most consequence and the least consensus.

ApproachReproducibleGets patchesVerdict
FROM node:22NoAutomaticallyToo loose for production
FROM node:22-slimNoAutomaticallyCommon, still floating
FROM node@sha256:…YesNeverReproducible and stale
Digest + automated bump PRsYesOn reviewThe answer

The middle two rows are the two failure modes, and they are opposites.

A floating tag means today’s build and tomorrow’s build start from different layers. Nothing tells you when it changed. A CI failure on a branch nobody touched is usually this.

A pinned digest with no upgrade process means running an unpatched base indefinitely. Six months later there is a published vulnerability in a base image nobody has bumped, and the pinning that was supposed to be a good practice is the reason.

Both, with automation:

# node:22-slim — bumped 2026-09-01 by automation
FROM node@sha256:0000000000000000000000000000000000000000000000000000000000000000 AS runtime

The comment records what the digest corresponded to, so a human reading the file knows what they are looking at. Automation opens a pull request when the tag moves. The upgrade is then a reviewable event with a CI run behind it, rather than something that happened silently or did not happen at all.

Distroless and minimal bases remove the shell and package manager entirely. Smaller attack surface, fewer packages for a scanner to flag, and genuinely harder to debug — kubectl exec into a container with no shell is not a productive experience, and the workaround is ephemeral debug containers, which your cluster and your team need to support. Worth it for anything internet-facing; weigh the operational cost honestly rather than adopting it because it scores well.

Dockerfile changes deserve specific attention, and the list is short enough to internalise.

The base image changed. To what, and was the bump reviewed or auto-merged?

A new RUN reaching the network. What is it fetching, from where, is it pinned, and is the URL under somebody else’s control?

COPY . . moved earlier. Cache invalidation, and a wider surface for accidental inclusion.

USER removed or absent. Running as root should be explained.

A new ARG. Build arguments are visible in image historydocker history shows them. An ARG is not a place for a token.

A new system package. Same supply-chain question as any dependency, and does it belong in runtime or only in build?

EXPOSE or CMD changed. These decide what actually runs.

Package installs without version pins. apt-get install curl resolves differently over time; npm ci against a committed lockfile does not.

The cheapest quality control available for Dockerfiles, and the one most repositories skip.

A Dockerfile linter catches a specific and useful set of problems: missing USER instructions, unpinned base images, apt-get install without --no-install-recommends, ADD where COPY would do, multiple RUN instructions that should be combined, and latest tags.

Run it on every pull request. It takes seconds and has no dependencies beyond the linter itself.

Baseline existing findings. A linter introduced to a mature repository reports everything at once. Block new findings, work the baseline down deliberately, or the team disables it within a week.

Suppress with a reason, in the file. An inline ignore comment explaining why a rule does not apply is reviewable. A globally disabled rule is invisible six months later.

Do not treat every rule as blocking. A missing label is advisory; a base image tagged latest in a production build is not. Treating them identically teaches people to bypass the whole check.

The rules worth making blocking in most repositories: no latest base tags, no ADD for local files, a USER instruction present, and no secrets in ARG where the linter can detect the pattern.

A Dockerfile answers how something is built. Nothing in the repository answers why it is built that way, and those decisions get re-litigated.

Comment the non-obvious lines. A RUN installing an unexpected system package should say what needs it. Six months later nobody remembers, and the safe assumption becomes leaving it in forever.

Record the base image choice. Why slim rather than the full image, or distroless rather than either. This is a decision with operational consequences — debuggability, size, patch cadence — and it deserves a sentence.

Say what the image is not for. A note that this image is not intended to run as root, or is not suitable for local development because it lacks a shell, prevents somebody helpfully “fixing” it.

Keep the README’s build section current. How to build locally, what the targets are, what build arguments exist. If a contributor has to read CI to work out how to build the image, that is the gap to close.

None of this is documentation for its own sake. Every item on that list is something a future reader would otherwise get wrong, and the cost of getting a Dockerfile wrong is measured in what ends up running in production.

Sometimes a build genuinely needs a credential — a private package registry, a licensed dependency.

Not ARG. Visible in history.

Not COPY. Persists in the layer.

BuildKit secret mounts. The secret is mounted for one RUN instruction and does not persist:

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

In Actions, docker/build-push-action takes a secrets input that wires this up from a workflow secret.

SSH forwarding (--mount=type=ssh) handles private Git dependencies without the key entering the image.

The general rule: if a credential is needed to build, it should be mounted for the instruction that needs it and gone afterwards. Anything else leaves it in a layer.

The build-reproducibility question, layer by layer.

Language dependencies: commit the lockfile and use the install command that respects it. npm ci, not npm install. pip install -r requirements.txt with pinned versions, or a lockfile-based tool. go.sum. This is the highest-value pinning and the easiest.

System packages: apt-get install -y curl=8.5.0-2ubuntu10.6 is reproducible until that exact version is removed from the distribution’s repository, at which point the build breaks entirely — distributions do not keep old package versions indefinitely. Most teams therefore accept unpinned system packages and rely on the base image digest to bound the variation, which is a reasonable position as long as it is a decision somebody made rather than an oversight. The middle ground, --no-install-recommends plus a pinned base, keeps the set of installed packages small and predictable without the brittleness of exact version pins.

Downloaded binaries: always verify. A checksum at minimum:

RUN curl -fsSLo /tmp/tool.tar.gz https://example.com/tool-1.2.3.tar.gz \
&& echo "abc123… /tmp/tool.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/tool.tar.gz -C /usr/local/bin \
&& rm /tmp/tool.tar.gz

Without the checksum, the build trusts whatever is at that URL today. curl | sh is the version of this with no verification at all, and it appears in a great many Dockerfiles.

Pinning by digest only works if something proposes the bumps. Left manual, it does not happen.

Automated dependency tooling handles it. Dependabot and similar tools understand FROM lines and open pull requests when a pinned base has a newer digest for the same tag. That converts an invisible staleness problem into a visible queue.

The pull request is not the hard part; reviewing it is. A base image bump is a diff of one line and a change of everything underneath it. What a reviewer can actually do:

Read the base image’s changelog for the version range being crossed. A patch bump is usually routine; a minor or major bump to a language runtime is not.

Look at what CI says. A full build, the test suite run inside the image, and a vulnerability scan of the result. If those three pass, most patch-level bumps are fine.

Check the size delta. A sudden jump usually means the base changed more than the version number suggests.

Merge them promptly. A queue of eleven open base image bumps is worse than none, because the team stops reading them and the eventual merge crosses many versions at once.

Group related bumps. If three services share a base image, bumping them in one pull request is easier to reason about than three that will be reviewed at different times and produce a fleet running different bases.

Where automation should stop. Do not auto-merge base image bumps for anything internet-facing or handling sensitive data, even on green CI. A base image change is a change to every package in the runtime, and green tests confirm your code still works rather than that the new base is sound.

No .dockerignore. Slow builds and .env files in layers.

.git in the image. The entire history, including removed secrets.

Secrets in ARG. Readable from image history.

Deleting a secret in a later RUN. Still in the earlier layer.

Floating base tags in production. Unannounced changes.

Pinned digests with no upgrade automation. Reproducible and unpatched.

Running as root. One line, routinely missed.

npm install instead of npm ci. Ignores the lockfile.

curl | sh. Executes whatever is at that URL today.

Copying source before dependencies. Every source change reinstalls everything.

A separate Dockerfile.dev. Diverges from production silently.

A useful discipline: periodically look at what you actually shipped rather than what you intended to ship.

docker history shows the instructions and the size each contributed. A layer that is unexpectedly large is usually a cache that was not cleaned in the same RUN, or a COPY that took more than intended.

Listing the filesystem of the final image reveals what is actually there. Teams are routinely surprised — build tooling that was supposed to stay in an earlier stage, test fixtures, documentation, an entire .git directory.

A vulnerability scan enumerates the packages present. If the list contains a compiler, a package manager or curl in a runtime image, ask whether they need to be.

The size trend over time is a decent proxy for drift. An image that has grown by 40% over a year without a deliberate reason has accumulated something.

The three questions worth asking about anything found: does the application need it at runtime, does it increase the attack surface, and can it move to a build stage? Most of what accumulates fails the first question and moves cleanly under the third.

This is a quarterly exercise rather than a per-pull-request one. It takes twenty minutes and it is the only way to notice the slow accumulation that no individual change caused.

A Dockerfile is a recipe whose ingredients can change without the recipe changing. Version control captures the recipe; pinning captures the ingredients.

Both are needed. A committed Dockerfile with FROM node:22 and apt-get install curl is version-controlled and not reproducible — the file is fixed and what it produces is not.

  • The Dockerfile belongs with the application, reviewed in the same pull request as the code
  • .dockerignore prevents .git and .env reaching layers; it is a security control
  • A secret in a layer stays there even if a later instruction deletes it
  • Multi-stage builds keep build tooling and source out of the runtime image
  • Pin base images by digest and automate the bump; either alone is a failure mode
  • Build arguments are visible in image history — use BuildKit secret mounts instead
  • Commit lockfiles and use install commands that respect them
  • Verify anything downloaded during a build with a checksum

Use a disposable directory and local Docker. No registry credentials.

  1. Write a single-stage Dockerfile with COPY . . and no .dockerignore. Put a .env file containing TOKEN=placeholder in the directory. Build it.

  2. Run docker run --rm <image> cat .env. Predict: is it there?

  3. Add RUN rm .env after the COPY. Rebuild. Use docker history and inspect the earlier layers. Predict: is it gone?

  4. Add a .dockerignore with .env. Rebuild. Confirm.

  5. Add ARG TOKEN=placeholder and build. Run docker history --no-trunc and look for the value. Predict: visible?

  6. Convert to multi-stage with a USER instruction. Compare docker images sizes before and after.

  7. Change FROM node:22-slim to a digest pin. Build twice and compare the resulting digests.

  8. Delete the directory and the local images.

Lab: ship an infrastructure change through a pull requestPractise the review discipline that makes an infrastructure change reviewable before it is applied.

The GitOps and infrastructure repository templates are in the Professional Toolkit.