ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, publish everywhere, and make your work citation-ready with built-in SEO, AEO, and GEO discovery support. Zero reader paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeDevOpsThe image you deployed is not the image running today
DevOps

The image you deployed is not the image running today

J
Jay Tank
Sr.DevOps Enginner
September 2, 2026
7 min read
The image you deployed is not the image running today
#Docker#security#DevSecOps#DevOps#Kubernetes
👍2

You pin a version. FROM node:20.11.1. It feels responsible - you didn't reach for :latest, you named an exact release. Six weeks later a teammate rebuilds the same commit and gets a subtly different image: a patched base layer, a new libc, a package that resolved differently. Nothing in your Git history changed. The tag did.

This is the uncomfortable truth about container tags: a tag is a mutable pointer, not the image itself. It names a place where an image currently lives, and that place can be overwritten at any time. The only reference that identifies the exact bytes - and keeps identifying them forever - is the image's content digest: @sha256:….

This post is about why the gap between "I pinned a tag" and "I pinned the image" is one of the quieter, more expensive holes in a software supply chain, and about a small tool I wrote - digestlock - that fails the build the moment an image reference is about to ship without a digest. It lives at https://github.com/jay-tank/digestlock.

A tag is a pointer; a digest is the thing

When a registry stores an image, it addresses it by the SHA-256 hash of its content - the digest. That digest is derived from the bytes, so it is immutable by construction: change one byte of the image and you get a different digest. nginx@sha256:ea0f…c1 will refer to those exact bytes on every machine, forever, or refer to nothing at all.

A tag, by contrast, is just a human-friendly label pointing at a digest. nginx:1.27.3 means "whatever digest the nginx maintainers have currently attached to the label 1.27.3." That attachment is editable. Registries let you re-push a tag; maintainers move tags to ship fixes; mirrors and proxies can resolve the same tag to different content. The tag is a sticky note on a shelf, and the sticky note can be peeled off and moved to a different shelf while you're not looking.

So FROM nginx:1.27.3 does not say "build on these exact bytes." It says "build on whatever 1.27.3 points to at build time." Two builds, two answers. That is the reproducibility hole.

The two ways this bites

Non-reproducible builds. Reproducibility is the property that the same inputs produce the same output. If your inputs include a mutable tag, you have smuggled a moving part into a process you believed was deterministic. A build that passes CI today can produce a different image next month with no code change - which means "it works on the commit I tested" is no longer a guarantee you can make. Debugging "works in the image from Tuesday, breaks in the image from Friday" is miserable precisely because the diff isn't in your repository.

Supply-chain exposure. This is the sharper edge, and it is why image pinning moved from a nice-to-have to a headline through 2024–2025. If an attacker can move a tag - by compromising a maintainer account, a registry namespace, a typo-squatted mirror, or the registry itself - then every build and every cluster that pulls by that tag silently ingests the attacker's image on the next pull. Your Dockerfile didn't change. Your manifests didn't change. The bytes did. A reference pinned to @sha256: is immune to this class of attack: if the digest doesn't match, the pull fails, loudly. A tag-only reference has no such tripwire.

Both problems have the same root cause and the same fix: stop referring to images by a label that can move, and start referring to them by content.

Why the gap is so easy to leave open

An unpinned reference is completely silent about being unpinned.

  • It is the path of least resistance. Every tutorial, base-image README, and docker run example uses a bare tag. FROM python:3.12 is what your fingers type. Adding @sha256:… is extra work you have to choose to do.

  • It is invisible in review. A pull request that bumps redis:7.2.3 to redis:7.2.4 looks exactly right. The absence of a digest is a non-event on the diff - nobody reviews the thing that isn't there.

  • It works perfectly, until it doesn't. The tag-only image pulls, builds, and runs. There is no error, no warning, no degraded behavior - right up until the day the tag moves. The failure is deferred and detached from the decision that caused it.

  • It hides in more places than the Dockerfile. The base image in FROM is only one reference. Your Kubernetes Deployments, StatefulSets, DaemonSets and CronJobs each name an image:. Your docker-compose file names one per service. Any of them can be tag-only.

This is why "pin by digest" sits near the top of every container-hardening checklist and every reproducible-build guide - and why it is so reliably skipped. The fix has to be enforced somewhere a human won't forget: in CI, on every change.

The fix: require a digest before merge

The durable fix is a policy applied at the one moment it's cheap - before the reference merges:

  1. Pin production image references by digest. Keep the tag for readability, append the digest for immutability: nginx:1.27.3@sha256:…. Now the reference is both legible and locked.

  2. Resolve the digest deliberately. docker buildx imagetools inspect nginx:1.27.3 prints the current digest. Updating an image becomes an explicit, reviewable change to a hash - exactly what you want an image bump to be.

  3. Gate it in CI. Make "every image reference carries a digest" a check that fails the pull request, like a failing test - so the unpinned reference never lands.

Step three is the one usually missing, because it needs a tool that reads your Dockerfiles and manifests and knows which references are supposed to be pinned. That is what digestlock is.

How digestlock works

digestlock is a single static Go binary - standard library only, no dependencies, no cloud credentials, no image build, no daemon, no registry, no network. It reads your files as text and asks one question of every image reference: is this pinned to an immutable digest, or can it move under you?

It scans three kinds of file:

  • Dockerfiles (Dockerfile, *.Dockerfile, Dockerfile.*) - the image in every FROM, including multi-stage FROM … AS build and FROM --platform=….

  • Kubernetes manifests (.yaml / .yml) - every image: key, across Pods, Deployments, StatefulSets, DaemonSets, CronJobs and the rest.

  • docker-compose (.yaml / .yml) - every service image:.

For each reference it parses the name, tag and digest and applies one small policy. The parsing has one subtlety worth calling out: a registry host can carry a port (localhost:5000/app:dev), so you cannot just "split on the last colon" to find the tag - a naive parser reads 5000/app as a tag. digestlock isolates the final path component first and only reads a tag from there, so a host:port prefix is never mistaken for a version.

What it doesn't flag - keeping false positives near zero

A gate that cries wolf gets disabled. digestlock deliberately stays silent on the references that aren't images to pin:

  • FROM scratch - the empty base; there is nothing to pin.

  • Build-stage references - a FROM build that points at an earlier FROM … AS build is a stage-to-stage reference inside the same Dockerfile, not a registry pull. A COPY --from=build is likewise never a base image.

  • Templated / variable references - FROM ${BASE_IMAGE}, or a Helm image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}". digestlock can't resolve these statically, so it leaves them alone rather than guessing wrong.

  • Local build contexts - a compose service with build: . and no image: has no reference to pin.

  • Already-pinned images - anything carrying @sha256:… is clean, full stop.

The two rules

digestlock has exactly two rules - one blocker, one warning:

  • DL001 (blocker) - a reference on :latest, or with no tag at all (Docker resolves that to :latest), and no digest. This is the maximally-mutable case: non-reproducible and directly in the path of a tag hijack.

  • DL002 (warning) - a reference pinned to a specific tag (e.g. :1.27.3) but still no digest. Much better than :latest, but the tag is still a movable pointer, so digestlock nudges you to add the digest. Under --strict, this fails the build too.

Every finding points at the exact file and line, echoes the raw reference, and shows how to pin it:

● 2 mutable :latest image(s):
  Dockerfile:10             image "debian" has no tag, so it resolves to the mutable :latest with no digest
      ↳ pin by immutable digest, e.g. FROM debian@sha256:<digest>
      [DL001 · blocker]
  k8s/deploy.yaml:14        image "nginx:latest" uses the mutable :latest tag with no digest
      ↳ pin by immutable digest, e.g. image: nginx@sha256:<digest>
      [DL001 · blocker]

● 1 tag-pinned but digest-less image(s):
  compose.yml:3            image "postgres:16" is pinned to tag "16" but not to an immutable @sha256: digest
      ↳ pin by immutable digest, e.g. image: postgres:16@sha256:<digest>
      [DL002 · warning]

2 blockers · 1 warning

Before and after

Here is the reference that opens the gap - it builds cleanly and reads fine in review:

FROM node:20.11.1

digestlock flags it DL002. The fix is to append the digest, keeping the tag for humans:

FROM node:20.11.1@sha256:ea0f7c...c1

Resolve the digest with one command:

docker buildx imagetools inspect node:20.11.1
# Digest: sha256:ea0f7c...c1

The identical name:tag@sha256:… form works in a Kubernetes image: and a docker-compose image:. Now the reference is both readable and immutable: the same bytes on every build, every pull, every cluster - and a hijacked tag can no longer substitute an image behind your back.

Try it

go install github.com/jay-tank/digestlock@latest

digestlock                 # scan the current directory
digestlock ./deploy        # scan a path
digestlock --strict        # fail on tag-only pins (DL002) too
digestlock --json          # machine-readable output

In CI it's one line:

- run: go run github.com/jay-tank/digestlock@latest ./

Exit 0 when every reference is digest-pinned, 1 on a blocker (or any finding under --strict). Suppress a specific case with a digestlock:ignore comment on the reference's line, or a path substring in a .digestlockignore file.

What it is, and what it isn't

digestlock is a fast, zero-config static gate for one question: is this image reference pinned to an immutable digest? It is honest about being a text scanner, not a full Dockerfile/Kubernetes/compose parser - it reads FROM and image: references, and a reference assembled from variables at build time is deliberately skipped rather than guessed at. And it never contacts a registry, so it checks that you pinned a digest, not that the digest is correct or still published - that verification belongs to your registry and admission tooling.

What it does do is close the gap that review and terraform apply-style success will never catch for you: it fails the commit that ships an image the world can quietly swap out later. The whole tool is MIT-licensed and lives at https://github.com/jay-tank/digestlock - a single dependency-free binary you can drop into a pre-commit hook or a CI job today.

Pin the bytes, not the label. The image you deploy should be the image that runs - this week, next month, and after someone tries to move the tag.

Comments (0)

Login to post a comment.

J
Jay Tank

Sr.DevOps Enginner

🚀 Platform Engineer building infrastructure for AI-native systems - MCP, Zero Trust, and security automation for Fintech & Agentic AI.

Subscribe to Jay Tank's Newsletter

Trending on ZyVOP

From Data Engineering to AI Engineering: The New Stack Every Data Engineer Should Know in 2026

Five years ago, if you called yourself a Data Engineer, your world revolved around ETL pipelines, data lakes, Spark jobs, warehouses, orchestration tools and da...

Abhinav Verma
Abhinav Verma·
6 minSep 2

Five ways to invalidate your prompt cache

Part 2 of 2 on prompt caching. Part 1 covered the economics. Part 1 established the prize: caching cut an 80-turn agent session from $54.08 to $6.91 in my cost ...

Lê Đức Minh
Lê Đức Minh·
9 minAug 17

From SEO to GEO: Structuring Product Data for AI Shopping Agents

A shopper opens ChatGPT and types: "Find me a waterproof carry-on under $200 that ships in two days." The AI agent queries multiple merchant catalogs simultaneo...

Hussnain Shahid
Hussnain Shahid·
8 minSep 2

Claude Fable 5.1 and Mythos 5.1: One Model, Two Access Levels

Claude Fable 5.1 and Mythos 5.1 are the same underlying model with different safeguards and access rules. The release says as much about the governance of frontier AI capability as it does about benchmarks.

Sanju Singh
Sanju Singh·
9 minSep 2

My daily check that my pages were still alive was watching four emails I had sent to other people, and three of my own pages were not on the list

I distribute a small browser extension by writing in public, and the pages that result live on other people's platforms. So I run a check every morning: fetch e...

M
Mathieu Ades·
3 minSep 2