Ask which commit produced the image running in production. If the answer takes more than a few seconds, the chain between your repository and your infrastructure has a gap in it.
The gap is almost never deliberate. It appears because a Git commit and a container image are different kinds of object, connected only by whatever identity somebody recorded at build time — and the default is to record nothing but a tag.
The chain
Section titled “The chain”A vertical sequence: a Git commit; the Dockerfile it contains; a CI build; a container image identified by a digest; a registry; and a deployment that references the digest.
Every link needs something recorded, or the chain breaks at that point and cannot be reconstructed afterwards from a CI log that has expired.
The short answer
Section titled “The short answer”The Dockerfile lives with the application. Changes go through pull requests. CI builds on every pull request to prove the build works, and pushes only from the default branch or a tag. Images carry the commit SHA as a label and as a tag. Deployments reference digests, not tags.
Everything below is why each of those is worth the discipline.
The Dockerfile is source
Section titled “The Dockerfile is source”It belongs in the repository whose application it builds, at the root, alongside the code it packages.
Why with the application: the Dockerfile changes when the application’s dependencies change. Adding a native library means changing both, and they should change in one reviewable commit. A separate “docker repository” holding everyone’s Dockerfiles means two pull requests and an ordering problem, and it becomes a coordination bottleneck within a quarter.
Review it like code. A Dockerfile decides what runs as root, what ships in the image, what base you inherit and what network calls happen during a build. Those are security decisions, and in a repository where anything sensitive is built they belong under CODEOWNERS so somebody who would recognise a bad one is asked to look.
.dockerignore is part of the source. Without it, the build context includes .git, node_modules, local .env files and anything else in the directory — slower builds, and a real risk of secrets ending up in a layer.
.git.githubnode_modules*.md.env.env.*!.env.exampledistcoverageDockerfile*compose*.yamlThe .env line is doing security work rather than performance work. A COPY . . in a Dockerfile with no .dockerignore copies whatever .env file the developer had locally into the image, where it stays in a layer forever.
Build on pull requests, push on merge
Section titled “Build on pull requests, push on merge”The separation that mirrors Terraform’s plan and apply.
On pull requests: build, do not push. Proves the Dockerfile works and the build succeeds. Scan the result. Run the tests inside it if that is how you test. Then discard it.
On merge to the default branch: build and push. Now the artifact is one that came from reviewed code.
On a tag: build, push, and mark it as a release. The full release path is covered in the Docker release workflow, including signing and changelogs.
Why the separation matters: a pull request can modify the Dockerfile. A Dockerfile can run arbitrary commands during build, including reaching for whatever credentials the build environment holds. A build that cannot push is a build whose blast radius is one runner; a build that can push registry credentials is a build that can publish an image with your organisation’s name on it.
Recording identity
Section titled “Recording identity”The step that makes the chain traceable, and it costs almost nothing.
As OCI labels, baked into the image:
ARG GIT_SHAARG BUILD_DATEARG SOURCE_URL
LABEL org.opencontainers.image.revision="$GIT_SHA" \ org.opencontainers.image.created="$BUILD_DATE" \ org.opencontainers.image.source="$SOURCE_URL"Labels travel with the image. docker inspect on a running container answers “which commit is this?” without needing a CI log, and org.opencontainers.image.source is what links a package to its repository on GHCR.
As a tag, so the image is findable by commit:
ghcr.io/example-org/api:sha-a1b2c3dghcr.io/example-org/api:v2.4.1Set created from the commit, not from the build clock, if you care about reproducibility. A build timestamp changes every run and makes otherwise identical builds produce different digests — which defeats the point of comparing them, and makes it impossible to tell a genuine change from a rebuild.
The rule to internalise: a Git SHA identifies source history; a digest identifies built content. They are related and not interchangeable, which is the subject of Git SHA container images.
Base images
Section titled “Base images”The line that determines what you inherit.
FROM node:22-slimThat tag moves. It is reassigned as patches ship, which is usually what you want for security and is not reproducible.
# node:22-slim as of 2026-09-01FROM node@sha256:0000000000000000000000000000000000000000000000000000000000000000A digest is immutable. The same build tomorrow starts from the same layers.
The practical answer is both: pin the digest, record the tag it corresponded to in a comment, and let automation propose upgrades as pull requests. That way the build is reproducible and the upgrade is a reviewable event rather than something that happened silently overnight.
Where the trade-off falls differently: a base image pinned by digest does not receive security patches until somebody bumps it. Pinning without an upgrade process means running an unpatched base indefinitely, which is worse than a floating tag. The pinning is only half the answer; the automation is the other half.
Version controlling Dockerfiles covers this in depth.
Multi-stage builds
Section titled “Multi-stage builds”The default structure for anything compiled or bundled.
# syntax=docker/dockerfile:1
FROM node:22-slim AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:22-slim AS runtimeWORKDIR /appENV NODE_ENV=productionCOPY package*.json ./RUN npm ci --omit=dev && npm cache clean --forceCOPY --from=build /app/dist ./distUSER nodeCMD ["node", "dist/server.js"]Three properties worth noticing:
Build tooling does not ship. Compilers, dev dependencies and source stay in the build stage. Smaller image, smaller attack surface.
Layer ordering follows change frequency. package*.json is copied and installed before the source, so a source change does not invalidate the dependency layer. This is a build-speed decision that also makes CI cheaper, and it is the single most common thing missing from a slow Dockerfile.
USER node means the container does not run as root. This is a one-line change that reviewers should look for and frequently do not.
The workflow
Section titled “The workflow”name: Container
on: pull_request: push: branches: [main] tags: ['v*']
permissions: contents: read packages: write id-token: write
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- name: Log in to GHCR if: github.event_name != 'pull_request' uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Derive tags and labels id: meta uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} tags: | type=sha,prefix=sha-,format=long type=ref,event=branch type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}}
- name: Build and push uses: docker/build-push-action@v7 with: context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max provenance: trueThe lines that carry the design:
push: is conditional on not being a pull request. The structural separation, expressed once.
Login is skipped on pull requests. No credentials in the environment at all, rather than credentials present but unused.
docker/metadata-action derives tags and OCI labels from the Git context, which is how the identity gets recorded without anybody maintaining a shell script.
provenance: true attaches build provenance — see artifact attestations for what that buys.
type=sha,format=long uses the full SHA. Short SHAs collide as a repository grows, and the argument for the full one is in Git SHA container images.
cache-from and cache-to use the Actions cache, which is scoped per repository and evicted by age and size — worth knowing when a build is unexpectedly slow after a quiet week.
Build caching and reproducibility
Section titled “Build caching and reproducibility”Two goals that pull against each other, and it is worth knowing which you are optimising.
Caching makes CI fast. cache-from/cache-to with the GitHub Actions cache means a build that changes only application source reuses the dependency layers. On a large image this is the difference between a two-minute build and a twelve-minute one.
Caching is not the enemy of reproducibility. A cache hit produces the same layer that would have been produced without the cache; it just skips the work. What breaks reproducibility is what happens inside the layers.
What actually makes builds non-reproducible:
Unpinned package installs. apt-get install curl resolves whatever version the repository has today. npm ci against a committed lockfile is reproducible; npm install is not.
Timestamps embedded at build time. A BUILD_DATE from date changes every run and changes the digest.
Network fetches during build. RUN curl https://example.com/install.sh | sh downloads whatever is there now.
Floating base image tags. Covered above.
How far to take it. Full bit-for-bit reproducibility is achievable and expensive. Most teams should aim for something weaker and more useful: the same commit, built twice, produces functionally identical images, with pinned bases, committed lockfiles, and no unpinned network fetches. That is enough to make a rebuild trustworthy, which is the practical requirement.
What actually establishes what happened is not reproducibility but provenance: a signed record of which commit, which builder and which parameters produced this digest. That is what artifact attestations provide, and it is the right answer to “prove this image came from that commit”.
Testing in the pipeline
Section titled “Testing in the pipeline”Where container builds and test suites meet, and the ordering matters.
Lint the Dockerfile. Fast, catches missing USER instructions, unpinned bases, and apt-get without --no-install-recommends. Runs before any build.
Build. If it does not build, nothing else matters.
Test inside the image, not beside it. Running the test suite against the built image tests what you will ship, including its runtime dependencies and its entrypoint. Running it in a separate Node or Python step tests something adjacent.
Scan the image. Vulnerabilities in the base and in the installed packages. On pull requests this is advisory; the decision about whether a finding blocks belongs to your policy.
Then push, only if all of the above passed and only from a reviewed ref.
A subtlety about scanning: a scan of a pull request build tells you about the image that pull request would produce, which is genuinely useful. A scan of an image already in the registry tells you about something running, which is a different and equally necessary activity — new vulnerabilities are published against images that have not changed. Both are needed, and the second is a scheduled job rather than a pipeline step.
Deploy the digest
Section titled “Deploy the digest”The last link, and the one most often left as a tag.
A build’s output includes the image digest. Capture it and use it downstream:
- name: Build and push id: build uses: docker/build-push-action@v7 # ...
- name: Report digest run: echo "Digest ${{ steps.build.outputs.digest }}"Why the deployment should reference a digest rather than a tag: a tag can be reassigned between the moment staging tested it and the moment production pulls it. Two deployments of api:main two hours apart can be different images with nothing to indicate it. A digest cannot be anything other than the exact content it names.
This is the property that makes environment promotion meaningful — promotion moves a digest, so what production runs is byte-identical to what was tested.
Monorepos and multiple images
Section titled “Monorepos and multiple images”A repository producing several images needs decisions the single-image case does not.
One Dockerfile per service, adjacent to the service.
services/├── api/│ ├── Dockerfile│ └── src/├── worker/│ ├── Dockerfile│ └── src/└── web/ ├── Dockerfile └── src/Build only what changed. A push touching services/api/ should not rebuild the worker and the web front end. Path-based change detection driving a matrix is the standard shape, and it is the same problem as Terraform’s changed-directory detection with the same trap: a change to shared code affects everything downstream, and path detection alone will miss it.
Shared code is the hard part. A packages/common/ that all three services import means a change there must rebuild all three. Either maintain the dependency map or rebuild everything when shared paths change — the second is cruder and much harder to get wrong.
One image name per service, one tag scheme across all of them. ghcr.io/org/api:sha-abc123 and ghcr.io/org/worker:sha-abc123 from the same commit. The shared SHA is what lets you answer “which versions of these three services were deployed together”.
Build context matters more here. context: services/api keeps the context small; context: . in a large monorepo sends the entire repository to the builder. If a service genuinely needs shared code, a wider context with a good .dockerignore is the answer, not copying the shared code into each service directory.
What a reviewer should look for
Section titled “What a reviewer should look for”Dockerfile changes get less scrutiny than application changes and often deserve more.
A changed base image. What changed, and was the digest bumped deliberately or by an automated pull request nobody read?
A new RUN that fetches from the network. What is it downloading, from where, and is it pinned?
Anything that copies more than it needs. COPY . . early in a Dockerfile invalidates cache and can pull in files nobody intended.
A removed or missing USER. Running as root should be a deliberate, explained choice.
New build arguments. ARG values are visible in image history — docker history shows them — so a build argument is not a place for a secret.
Anything touching the entrypoint. It decides what actually runs.
A new package installed. Same supply-chain question as any dependency: what is it, who maintains it, and does it need to be in the runtime image or only the build stage?
Who is allowed to push
Section titled “Who is allowed to push”Worth an explicit policy, because the default is looser than most teams intend.
CI on the default branch and on tags. That is the whole list for anything that reaches an environment.
Not engineers from laptops. A locally built image has no reviewable provenance, was built from whatever was in the working tree — including uncommitted changes — and typically carries no attestation. It is also built on a machine with a different toolchain from CI, which is how “works on my machine” becomes an image in a registry.
Not pull request builds. Covered above.
Registry permissions should reflect this. If every engineer has write:packages on the organisation, the policy above is a convention rather than a control. Scope write access to the CI identity, and grant humans read.
The exception worth allowing: a scratch namespace for experimentation, clearly separated from anything deployable, with a retention policy that cleans it up. Blocking experimentation entirely pushes people to work around the control.
The signal that this has drifted: an image in the registry with no linked repository and no provenance attestation. That is an image somebody pushed by hand, and it is worth asking how.
Common mistakes
Section titled “Common mistakes”No .dockerignore. Slow builds, and .env files copied into layers.
Pull request builds with registry write credentials. Submitted RUN instructions with your credentials.
Floating base image tags with no upgrade process. Reproducible builds and unpatched bases are both failures; you need pinning plus automation.
No commit identity in the image. The chain breaks and cannot be reconstructed later.
Short SHA tags. They collide.
Running as root. A one-line fix that reviewers routinely miss.
Building locally and pushing from a laptop. No provenance, no review, and nobody can tell later.
Deploying tags. The image tested and the image running can differ with nothing to show it.
Rebuilding per environment. The artifact tested is not the artifact shipped.
Local development and the same Dockerfile
Section titled “Local development and the same Dockerfile”A recurring tension: the image that ships should be minimal, and the environment a developer works in should be convenient. Those want different things.
Multi-stage targets solve most of it. Add a dev stage that inherits from the build stage, keeps the tooling, and runs a watcher:
FROM node:22-slim AS devWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .CMD ["npm", "run", "dev"]docker build --target dev gives developers what they want; the default target still produces the minimal runtime image. One file, one review, no drift between what developers run and what ships.
Compose references the target. A compose.yaml with build: { context: ., target: dev } and a bind mount over the source is the standard local setup, covered in Docker Compose with Git.
What to avoid: a separate Dockerfile.dev. It starts identical and diverges. Six months later the development environment has a different Node version from production and somebody spends a day on a bug that only reproduces in one of them. If two files are genuinely needed, at least have the second one FROM the first.
CI should build the production target, always. A pipeline that builds the dev target because it is cached and faster is testing something you do not ship.
Mental model
Section titled “Mental model”A commit is a point in history. An image is a piece of content. The workflow’s job is to record which produced which, at the moment it happens, in the image itself.
Everything follows: labels because the connection must travel with the artifact; digests because content identity must be immutable; build-on-PR-push-on-merge because only reviewed source should produce a published artifact.
What you learned
Section titled “What you learned”- The Dockerfile belongs with the application it builds, and is reviewed like code
.dockerignoreis a security control as much as a performance one- Build on pull requests without credentials; push only from reviewed refs
- Record the commit as an OCI label and as a tag, at build time
- Pin base images by digest and automate the upgrade — pinning alone means running unpatched
- Multi-stage builds keep build tooling out of the runtime image
- Deployments reference digests; tags are for humans
Exercise
Section titled “Exercise”Use a disposable repository. No registry credentials needed for most of it.
-
Write a small multi-stage Dockerfile with a
USERinstruction. Build it locally. -
Add
LABEL org.opencontainers.image.revisionfrom a build argument. Build with your current commit SHA and rundocker inspectto read it back. -
Build without a
.dockerignore, then add one. Compare build context sizes in the output. -
Put a fake
.envfile in the directory withSECRET=placeholder, build without.dockerignore, and check whether it is in the image. -
Build the same Dockerfile twice. Compare the digests. Predict: identical?
-
Change
FROM node:22-slimto a digest pin. Rebuild. Compare digests again. -
Add a CI workflow that builds on pull requests without pushing. Confirm no registry credentials are configured on that path.
-
Delete the repository and the local images.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.