Pinning Base Image Digests with sha256
A tag like :ubuntu can point at different bytes tomorrow, so two rebuilds of the same commit can diverge. This page shows how to resolve a tag to its immutable sha256 digest, pin that digest in devcontainer.json, and refresh it deliberately — turning your base image from a moving pointer into a fixed, auditable artifact.
The reason this matters is that a tag is a label, not an identity. When a publisher pushes a new build of mcr.microsoft.com/devcontainers/base:ubuntu, the tag silently re-points to the new manifest while every reference that names only the tag inherits those new bytes on the next pull. A sha256 digest, by contrast, is computed from the manifest content itself, so it names exactly one image for as long as that image exists in the registry. Pinning the digest is what lets you say, with certainty, that the container a teammate rebuilds next month is the same container that passed CI today — the tag can drift underneath you, but base:tag@sha256:… cannot.
Reach for digest pinning whenever byte-for-byte reproducibility is a requirement rather than a nice-to-have: shared devcontainers that must behave identically across a team, images that feed a compliance or supply-chain audit, or any environment where "works on my machine" has to mean "works on everyone's machine." The mental model is that your base image is a dependency like any package in a lockfile. You resolve it once to a concrete version, commit that resolution, and bump it on purpose through a reviewed change — never by accident because someone happened to pull on a day the upstream tag had moved.
Prerequisites
You need a container engine and the base image tag you intend to standardize on.
- Docker or Podman available to pull and inspect images.
- The base image tag you want to pin.
devcontainer.jsonyou can edit and commit.
None of these prerequisites are heavy, but the order matters. You resolve a digest against a specific registry and a specific architecture, so the machine you run docker pull and docker inspect on should be the same platform your team builds for — or you should be explicit about the platform, which the multi-architecture note below covers. The digest you pin also has to come from the registry you will actually pull from at build time; a digest resolved against a local cache or a mirror that later diverges is not the same guarantee as one resolved against the canonical mcr.microsoft.com reference in the examples here.
The detail people most often get wrong is treating the digest as something you can hand-write or copy from documentation. A sha256 value is only meaningful when it was produced by inspecting the exact tag you intend to pin, on the exact registry you intend to pull from. Never transcribe a digest from a blog post or an old ticket; always resolve it live with the commands in the next section so the value you commit is one you have verified against the running engine.
Step-by-Step Implementation
- Pull the tag you want to standardize on.
docker pull mcr.microsoft.com/devcontainers/base:ubuntu
Pulling first is what makes the resolution trustworthy. docker pull contacts the registry, downloads the manifest the tag currently points at, and records the digest that came back in your local metadata. If you skip the pull and inspect an image you happen to already have cached, you may read a stale digest from a previous week's build rather than the bytes the tag resolves to right now. Running the pull deliberately, against the full mcr.microsoft.com/devcontainers/base:ubuntu reference rather than a short local alias, guarantees the digest you read in the next step is the live, canonical one.
- Read the resolved digest the tag currently points at.
docker inspect --format='{{index .RepoDigests 0}}' \
mcr.microsoft.com/devcontainers/base:ubuntu
The --format template pulls the first entry out of the image's RepoDigests array, which is the registry-qualified name@sha256:… string Docker recorded during the pull. Reading RepoDigests rather than the image's own Id is deliberate: the Id is the digest of the local image configuration, while RepoDigests is the manifest digest the registry actually serves, and only the latter is the value you can pin and re-pull elsewhere. Indexing 0 returns the digest for the registry you pulled from; if the same image has been tagged into multiple registries locally the array can hold more than one entry, so confirm the string you copy starts with mcr.microsoft.com/devcontainers/base before you trust it.
- Pin the digest in
devcontainer.json, keeping the tag for readability.
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED_DIGEST",
"remoteUser": "vscode"
}
Keeping both the :ubuntu tag and the @sha256:PINNED_DIGEST suffix in the image field is the point of this form. The digest after the @ is what the engine actually resolves — it is the authoritative identity, and if the tag and digest ever disagree the digest wins — while the tag stays in the string purely so a human reading devcontainer.json can see at a glance what the base is meant to be. Replace PINNED_DIGEST with the real sha256 value from step two; leaving the placeholder in place fails the pull with a manifest error rather than falling back to the tag, which is the intended failure mode because it forces you to commit a resolved digest rather than an unpinned reference.
- Refresh on a cadence by re-resolving and committing the new digest as a reviewed change.
docker pull mcr.microsoft.com/devcontainers/base:ubuntu # then re-read the digest
This final step is the one teams skip, and skipping it turns a strength into a liability. A pinned digest freezes the base perfectly, which also means it freezes every unpatched vulnerability the base carried on the day you pinned it. Re-running the pull against the tag re-resolves it to whatever the publisher has shipped since, and re-reading RepoDigests gives you the new value to commit. Because the refresh flows through the same resolve-and-commit path as the initial pin, the security update lands as a reviewed diff in devcontainer.json — visible in history, attributable to a person, and testable in CI before it reaches anyone's machine.
Common Pitfalls
The failures below all come from either not pinning, or pinning once and never refreshing.
A subtler failure shows up on multi-architecture teams. The digest you read from RepoDigests is the digest for the single platform you pulled — an ARM laptop resolves a different sha256 than an x86 CI runner, even for the same :ubuntu tag. If you pin the ARM digest and a colleague on x86 rebuilds, the pull fails because that specific digest has no image for their platform. When your team spans architectures, pin the digest of the multi-arch manifest list rather than a per-platform digest, or maintain separate pins keyed by platform; either way, resolve on the platform you intend the pin to serve and confirm it before committing.
The other recurring trap is the pull cache masking a stale pin. Because a pinned reference is immutable, Docker will happily serve the layers it already has for that digest without ever contacting the registry, which is exactly what you want for reproducibility but also means a digest that was garbage-collected upstream can keep "working" locally long after it has vanished for everyone doing a clean pull. Do not discover a dead pin from a broken CI job weeks later — when you refresh, run the pull on a machine with a cold cache, or prune first, so the resolve actually round-trips to the registry and proves the digest is still fetchable.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Rebuild differs on same commit | Tag moved to new bytes | Pin the resolved @sha256 digest |
| Base never gets security updates | Pinned once, never refreshed | Refresh the digest on a cadence |
| Digest not found on pull | Digest garbage-collected upstream | Re-resolve the current tag and re-pin |
| Team unsure which base is live | Digest not recorded/changelogged | Record digest bumps in the changelog |
Conclusion
Pin the digest, then refresh it on purpose. base:tag@sha256:… names exactly one image forever, so rebuilds are byte-identical; refreshing on a reviewed cadence keeps the base patched without surrendering determinism. The invariant: upgrades are intentional commits, never accidental tag drift.
The strategic payoff is that your base image stops being a source of unexplained variance. When a rebuild misbehaves, you can rule the base out immediately because the sha256 in devcontainer.json is the same one that worked before — the change you are hunting for has to be in your own layers or config, not in bytes that shifted under a tag while you weren't looking. That single guarantee is what makes a devcontainer genuinely reproducible rather than merely convenient, and it is the foundation the rest of your image strategy sits on: there is little value in caching layers deterministically or scanning an image for CVEs if the thing underneath them can silently become a different image tomorrow.
This ties directly into the broader pin-and-cache discipline. Pinning the digest gives the build cache a stable key to hash against, so cache hits stay valid until you deliberately bump the pin; the two techniques reinforce each other, with the digest guaranteeing identity and the cache exploiting that identity for speed. Treat the pinned digest the way you treat a lockfile entry — resolved once, committed, reviewed on every bump, and recorded in the changelog so anyone can trace which base was live for a given release. Do that consistently and the base image becomes an auditable artifact you upgrade with intent, not a moving target you chase.
FAQ
Why keep the tag if I'm pinning the digest?
The tag documents intent and keeps the reference human-readable (:ubuntu@sha256:…), while the digest guarantees identity. Keeping both means a reader understands what the image is and the build resolves exactly one set of bytes. The digest is what enforces reproducibility; the tag is context. If you dropped the tag and pinned a bare base@sha256:…, the file would still build correctly, but the next person to open it would have to inspect the manifest just to learn whether they were looking at Ubuntu, Alpine, or something else entirely — the tag is what turns an opaque hash back into something a reviewer can reason about.
How often should I refresh the digest? On a deliberate cadence — monthly is common — or immediately when a scan flags a critical CVE in the current base. Treat it like any dependency bump: re-resolve the digest, re-scan, and commit it as a reviewed change so the upgrade is intentional and traceable. The right interval is a trade-off between staying patched and absorbing the churn of validating a new base, so pick a rhythm you will actually keep rather than an aspirational weekly bump that quietly lapses. Tying the refresh to an existing ritual — a sprint boundary or a monthly maintenance window — tends to survive better than a standalone reminder.
What if the pinned digest disappears upstream? Occasionally an upstream garbage-collects an old digest. Re-resolve the current tag to get a fresh digest and re-pin it, reviewing the change as usual. To reduce exposure, some teams mirror pinned base images into their own registry so the digest they depend on can't vanish. Mirroring also insulates you from upstream rate limits and outages, and it means the digest you pin is one you control the lifecycle of — the copy in your registry stays fetchable on your schedule, not the publisher's retention policy.
Does pinning a digest slow down my builds?
No. Resolving base:tag@sha256:… costs the same registry round-trip as resolving the tag alone, and once the layers for that digest are cached locally the engine reuses them without contacting the registry at all. If anything, a pinned digest makes the build cache more reliable, because the immutable reference is a stable key that cache hits can depend on rather than a tag whose meaning can shift between runs.
How do I verify the pin is actually taking effect?
After building, inspect the running image's RepoDigests and confirm the sha256 matches the value committed in devcontainer.json. If they agree, the build resolved the pin rather than falling back to a cached or tagged image. It is also worth running one clean build with a pruned cache after a refresh, so you prove the pinned digest is fetchable from the registry and not just being served from local layers left over from an earlier pull.
Can I pin digests for images pulled inside the Dockerfile too?
Yes, and you generally should. The same @sha256:… syntax works in a FROM line, so a devcontainer that builds from a Dockerfile can pin its base there exactly as devcontainer.json pins an image. Applying the discipline in both places closes the gap where the outer config is pinned but an inner FROM base:ubuntu still drifts, which would reintroduce the very non-determinism you pinned to eliminate.
Related
- Up to Container Registry Best Practices for Dev Images — the parent guide on image strategy.
- Choosing Between Alpine and Debian Base Images — picking the base you then pin.
- Multi-Architecture Builds for ARM & x86 — pinning across platform variants.