Stage 1: The build context, and what got in that you did not put there
The build starts by sending a directory to the builder. Not the files your Dockerfile names — the directory, minus whatever .dockerignore excludes. Then COPY . /app puts what survived into a layer.
The standard advice about secrets stops here, and it stops one directory short. Put .env in .dockerignore and the file does not reach the image. What reaches the image is the version-control history that file was committed to. Here is the whole thing, built and then read back out of the published image:
$ cat .dockerignore
.env
$ podman run --rm lab/app ls -a /app/.env
ls: cannot access '/app/.env': No such file or directory # the advice worked
$ podman run --rm lab/app ls -a /app | head -3
.
..
.git # and this came too
$ cd /tmp/rec/app && git log --oneline
e6cb927 remove real secrets
feb9502 add secrets by mistake
$ git show HEAD~1:.env
DB_PASSWORD=hunter2-production
STRIPE_KEY=sk_live_51H8xREALThe secret was removed from the working tree in a commit whose message says it was removed, and it is one git show away inside a published image. .dockerignore keeps a file out. It does not keep out the record of the file. The list of things worth adding to it beside .git: .svn, node_modules, *.pem, *.key, .terraform, and any directory your editor or your CI writes into.
Docker, the basics tells you that ${DB_PASSWORD} should come from a .env file that is not in version control. That is correct and this is the step after it: a file that is not in version control now may still be in version control’s history, and the history is a directory in your build context.
Verify: podman run --rm <your image> ls -a /app, or whatever your COPY target is, and look for a dot-directory you did not intend to ship. If you find .git, assume every secret that repository has ever held is published, and rotate them rather than rebuilding.
Stage 2: The layer that keeps what you deleted, and the config that keeps what you typed
An image is not a filesystem. It is an append-only sequence of diffs plus a document about them, and the two halves fail differently. The diffs keep everything you ever added. The document keeps everything you ever typed.
The diffs first. Build an image that fetches a deploy key, uses it, and deletes it in a later RUN — the canonical mistake, and one that has been wrong since 2013. The running container is clean; ls -la /root/.ssh/ inside it returns an empty directory. Now read the layer out of the registry instead of running it:
$ crane blob 127.0.0.1:5000/lab/app@sha256:c775b9640c8fb175... | tar xzO root/.ssh/deploy_key
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAA
DEPLOY-KEY-DO-NOT-SHIP-9f3a2b1c
-----END OPENSSH PRIVATE KEY-----One command, no privileges beyond being able to pull. The layer above it carries the whiteout that hides the file at runtime, exactly as 552 describes — that page’s whiteout table and its account of trusted.overlay.whiteout are correct and this page does not repeat them:
== sha256:6ef78b1ac9e3a440d6669aaad2618cde955ec2dc1db7cd2e49576ceeb71bf2d7
---------- 0/0 0 2026-09-04 00:59 root/.ssh/.wh.deploy_keyThe whiteout is an instruction to the thing assembling the filesystem. It is not an instruction to the registry, and the registry is where the layer is. That is the whole correction: the size consequence everyone knows, and the disclosure consequence almost nobody states.
And multi-stage builds fix half of this
Multi-stage is offered as the answer, and for the diffs it is one — a build argument consumed only in a discarded stage really does leave no trace in the final image’s history. But the image also carries a config blob: a small JSON document naming the entrypoint, the working directory, the environment and the layer list. (Two different documents get called “config” in this subject. This is the image’s own, the one the registry stores. The config.json inside an OCI runtime bundle is a different file, and it belongs to 552.) It is not a layer, so nothing squashes it, and it ships to everyone who pulls:
$ crane config lab/clean-multistage:1 | jq '.config.Env'
[
"PATH=/bin:/usr/bin",
"API_ENDPOINT=https://api.internal.example/v2",
"SENTRY_DSN=https://abc123def456@o42.ingest.example/7"
]That is the “clean” image. ENV lands in .config.Env and stays there through any number of stages. And --build-arg is written verbatim into the config’s history, which crane config, docker history and skopeo inspect all print:
"created_by": "ARG BUILD_TOKEN=s3cr3t-from-ci",
"created_by": "RUN |1 BUILD_TOKEN=s3cr3t-from-ci /bin/sh -c echo \"using $BUILD_TOKEN\" > /tmp/x # buildkit",BuildKit echoes the same string into the build log, so it is in your CI output as well. The fix that works is RUN --mount=type=secret, which leaves a history entry naming the command and nothing else. It has been available since BuildKit 0.4 and Docker 18.09, November 2018, and most Dockerfiles still use --build-arg.
The second hinge, for this stage. The shortest command on this page, and the one that finds the leak:
crane config <your image> | jq '.config.Env, [.history[].created_by]'Everything it prints ships to everyone who can pull the image, and none of it is in a layer — so no rm, no squash and no multi-stage build removes any of it. Read it for two things: a value in Env that is a credential rather than an address, and a created_by line containing an = followed by something you would not paste into a chat.
Verify: run the second hinge against the image you shipped most recently. If it prints something, note that rebuilding does not help — the old image is still in the registry and still pullable by digest. Rotate the credential; that is the only fix that exists at this stage.
Stage 3: The push, and everyone else who can move the name
A push uploads blobs and then writes one name. The name is a tag, and a tag is a pointer that anybody with write access to the repository can re-aim — including your own CI, at three in the morning, at a rebuild of the same version.
What the registry does not do when a tag moves is forget where it pointed before. On the reference registry the tag keeps a directory of every digest it has ever named:
lab/app/_manifests/tags/1.0/index/sha256/c323774d.../link
lab/app/_manifests/tags/1.0/index/sha256/d4a98cf7.../linkBoth manifests are still there, both still pullable by digest, and registry garbage-collect --delete-untagged reported 14 blobs marked, 0 blobs and 0 manifests eligible for deletion. The superseded image was not untagged, because from the registry’s point of view it is still in the tag’s history. This is why “we moved the tag, so the old one is gone” is false, and it is also why stage 6, when it arrives, arrives from a completely different direction.
Two deletes that look identical and are not
The same HTTP verb against the same endpoint does two different things depending on whether the reference is a tag or a digest. In distribution 3.0.0’s DeleteManifest handler:
if imh.Tag != "" {
dcontext.GetLogger(imh).Debug("DeleteImageTag")
tagService := imh.Repository.Tags(imh.Context)
if err := tagService.Untag(imh.Context, imh.Tag); err != nil {Measured: crane delete .../lab/app:1.0 removed one tag and left the manifest and a second tag also-latest intact. curl -X DELETE .../manifests/sha256:c323774d... returned 202 and left the repository with no tags at all — the manifest went, and every tag on it went with it. A cleanup script that deletes old tags and one that deletes old digests differ by one character in the reference and by everything in blast radius.
Verify: run the hinge against the tag your deployment uses and compare its Digest: line with the digest that is actually running. crane digest <repo>:<tag> is the one-line form. If they differ, nothing is broken yet — but the name and the thing have come apart, and stage 4 is about which of them your tools are using.
Stage 4: The digest — three of them, and what a copy does to each
552 explains what a digest is the hash of, and this page does not repeat it: the manifest names one config blob and an ordered list of layer blobs, and the image’s digest is the hash of that manifest document. What 552 does not say, and what everything below turns on, is the emphasis: it is the hash of those exact bytes. Not of the filesystem, not of the layer set, not of the meaning. Of the document, as serialised.
It is worth proving to yourself once, because every surprise in this section follows from it:
$ crane manifest lab/app:1.0 > m.json
$ sha256sum m.json
3f0c8b1d... m.json
$ crane digest lab/app:1.0
sha256:3f0c8b1d...One image, three digests
A multi-platform image built by a current buildx has three, and different tools report different ones:
index (what the tag resolves to) sha256:685308f8d9974a59941bf513f13238854995cf6f...
linux/amd64 manifest sha256:67d8814453e62f1acdbf25a1a87a4ca72728dc96...
attestation manifest sha256:095781921098225b48ba354c906fe8f08fee2e3c...podman image inspect --format '{{.Digest}}' reports the index. crane digest --platform linux/amd64 reports the platform manifest. cosign signs whichever reference string you hand it. If your pipeline signs the platform digest and your admission controller resolves the tag to the index digest, verification fails and nothing in either message says why.
Which also means pinning an index is not pinning a filesystem. One index digest, resolved on two machines:
$ crane export --platform linux/amd64 lab/svc@sha256:b35d79be... - | tar xO srv/app.txt
hello v1
$ crane export --platform linux/arm64 lab/svc@sha256:b35d79be... - | tar xO srv/app.txt
hello v1 -- ARM64 BUILDThat is the same pinned digest returning different software, chosen by the CPU of whoever pulled it — and it is why 552’s symptom table row “tags are mutable; only digests are not” is wrong. 552 states the mechanism correctly earlier on the same page and then publishes a table row that contradicts it; the table is the half that has not caught up. Podman will also pull an architecture it cannot run and warn rather than refuse — WARNING: image platform (linux/arm64) does not match the expected platform (linux/amd64) — so the exec format error arrives later, from a different machine, in a different week.
And a copy changes the digest more often than not
Seven ways of moving one unchanged image between repositories. Same layers, same filesystem, same behaviour; three different identities:
source lab/bk:1 = sha256:c86a09aa9cc1e505d3455de93ee087b527b0df43...
skopeo copy registry -> registry c86a09aa unchanged
crane copy registry -> registry c86a09aa unchanged
regctl image copy registry -> registry c86a09aa unchanged
skopeo copy via docker-archive f1c45587 CHANGED
skopeo copy via oci: layout 5c6cb901 CHANGED
podman pull then podman push f1c45587 CHANGED
podman save + load + push f1c45587 CHANGEDpodman pull followed by podman push — no tarball, no format conversion, the commonest mirroring recipe there is — changes the image’s digest. The reason is the emphasis at the top of this section: those tools parse the manifest and re-emit it, the OCI spec only recommends canonical JSON, and the re-emitted document differs by whitespace and key order. The bytes changed, so the hash changed, so the image has a new identity and any signature over the old one no longer applies to it.
Format alone does the same thing: build one filesystem twice, once as OCI and once as Docker v2s2, and the diff_ids are identical while the digests are not. Any tool that reconstructs a manifest is a tool that changes the image’s identity, and none of them says so.
Verify: take one image and copy it by whatever route your release process actually uses, then compare crane digest at both ends. If it changed, your mirror holds a different image from the one you signed — which is stage 5.
Stage 5: The things attached to it that are not in it
Signatures, SBOMs and provenance attestations are not properties of an image. They are separate objects, stored beside it, and the registry that holds both has no opinion about the relationship. This is the stage where the word “attached” does the most damage.
A signature is an ordinary manifest under a derived tag. Sign an image with cosign in its default mode and the repository gains a second object; the image is untouched. Which means anyone with delete rights on the repository can remove the signature with one HTTP request:
$ curl -X DELETE .../v2/lab/bk/manifests/sha256:be17bd52...
HTTP 202
$ crane digest 127.0.0.1:5000/lab/bk:1 # the image, unchanged:
sha256:c86a09aa9cc1e505d3455de93ee087b527b0df43d3871a277882d4299e853f8f
$ cosign verify ...
Error: no signatures found
$ podman pull 127.0.0.1:5000/lab/bk@sha256:c86a09aa... # still pulls, no complaint
142e7847387d184f055f9015da9c1287307bf325825047fd5e49f2770c0aabaeThe registry does not know what a signature is and will never refuse to serve an unsigned image. Verification is done by whoever chooses to do it, which in practice means an admission controller in the cluster — see Kubernetes, Honestly for where that sits. If you sign and nothing verifies, you have produced an artefact nobody reads.
The SBOM does not travel with the image
Provenance and SBOM attestations produced by buildx attach as extra manifests inside the index, under a platform of unknown/unknown. A platform matcher never selects them, because no machine is that platform. Pulling the attested image fetched three layers and nothing else, and podman image inspect reported the index digest with no mention that an attestation was in it.
That entry is the loudest branch of the hinge at the top of this page, and for most people it is how they first discover that their builds have been attaching provenance since buildx v0.10 in January 2023. It is also what breaks mirroring: skopeo copy without --all copies one platform and drops the rest, attestations included.
$ skopeo copy docker://.../lab/svc:1.0 docker://.../mirror/svc:1.0
source lab/svc:1.0 sha256:b35d79be... (2 platforms)
mirror/svc:1.0 sha256:d4a98cf7... (single manifest, amd64 only)The discovery mechanism is not there yet
552 says the Referrers API is how signatures and SBOMs are discovered. That is the specification’s intent and it is not yet the present tense. On distribution 3.0.0 — the reference implementation, and what registry:3 runs:
$ curl -i .../v2/lab/prov/referrers/sha256:685308f8...
HTTP/1.1 404 Not Found
404 page not foundIts router defines seven routes and none of them is referrers; the word does not appear in the shipped binary. cosign 2.6.1 and regctl both queried the endpoint, both got the 404, and both silently fell back to the tag scheme, which is what the distribution specification tells them to do. So on that registry a signature and an SBOM are discoverable only through ordinary, mutable tags:
$ crane ls 127.0.0.1:5000/lab/bk
1
sha256-c86a09aa9cc1e505d3455de93ee087b527b0df43d3871a277882d4299e853f8f
sha256-c86a09aa9cc1e505d3455de93ee087b527b0df43d3871a277882d4299e853f8f.sigThe one-request test for your own registry: push a manifest carrying a subject field and look for an OCI-Subject: header in the response. The distribution specification makes that header mandatory for registries that implement the API, so its absence is the answer.
One dated item belongs here. Docker has published a retirement schedule for Docker Content Trust and the Notary v1 service, with full shutdown announced for 8 December 2026, naming Sigstore/cosign and Notation as the replacements. Any runbook that still sets DOCKER_CONTENT_TRUST=1 is depending on a service with an end date.
Verify: run the hinge and look for a Platform: unknown/unknown line. If it is there, you have been publishing provenance and nothing in your pipeline has ever fetched it. If it is absent on an image you believe is signed, whatever signed it attached to a different digest.
Stage 6: When the digest stops resolving
Content addressing is global. Custody is not. A digest identifies the same bytes everywhere in the world, and whether those bytes are still served is a per-repository question that nothing about the digest can answer.
After a genuine garbage collection, the pinned manifest still returned HTTP 200 — and every blob it names returned 404 in that repository and 200 in a different repository of the same registry:
sha256:493c8d7688248d1d... => HTTP 404 (in lab/app)
sha256:493c8d7688248d1d... => HTTP 200 (in lab/svc)This is the hinge’s named exception arriving. Manifest-level tools — docker buildx imagetools inspect, crane manifest, skopeo inspect, docker manifest inspect — never request a blob, so all of them report a healthy image:
$ docker buildx imagetools inspect 127.0.0.1:5000/lab/app@sha256:d4a98cf7...
Name: 127.0.0.1:5000/lab/app@sha256:d4a98cf7...
MediaType: application/vnd.oci.image.manifest.v1+json
Digest: sha256:d4a98cf7...
$ crane pull 127.0.0.1:5000/lab/app@sha256:d4a98cf7... /tmp/x.tar
Error: GET .../blobs/sha256:493c8d76...: BLOB_UNKNOWN: blob unknown to registryThis is the mechanism behind an ImagePullBackOff that appears on a replaced node weeks after somebody enabled a policy to clean up untagged images — nothing changed in the deployment, nothing changed in the registry that day, and every tool anyone runs to check the image says it is fine. The nodes that already had the layers cached kept running until they were replaced.
Every managed registry has some rule about untagged manifests and unreferenced blobs. Find yours and read what it deletes rather than what it retains — the two are not complements of each other, and pinning a digest is not a claim on storage. Nobody is keeping your image for you.
Verify: for one digest you actually rely on, ask for a blob rather than a manifest — crane blob <repo>@<config digest> > /dev/null. A clean exit is the only evidence that the image can still be pulled. Everything else is paperwork.
Stage 7: Rebuilding it, and why you cannot
Every page on this subject stops at docker push. But the reader’s problem does not end when the digest stops resolving — it ends when they have to produce the image again and cannot. That is this stage, and it closes the loop back to stage 1.
Two builds of the same Dockerfile, the same context, and the same base image digest, two seconds apart:
a => sha256:cd406a03eac5efcce8d166998a15d4e4d4acfb305b59066f7b231a280d85ffdc
b => sha256:d9f0ed57f479e72d1f18ff5b7802cf6c5f77b587552cbbb9f4ee380638ff1fab
$ diff <(crane config ...:a) <(crane config ...:b)
< "created": "2026-09-04T01:05:33.691389688Z",
> "created": "2026-09-04T01:05:35.886727736Z",
< "sha256:33ada8b2db64a6b9bf64e88e153e13d99830db11efa9bc144b961ec7578c002c",
> "sha256:b799d038ec81e4cf8a67c1c60b90d7c993d5cf92fccefbf7ea8237b49a2e14af"The diff_ids differ too, because tar headers carry modification times. The build is not a function of its inputs.
Nor is the cache what people think it is. For RUN, the key is the instruction text — so an instruction whose output depends on the date is never re-run. Seven consecutive builds, each reporting #6 CACHED, with a stamp frozen at the first one while the copied files changed underneath it. That is RUN apt-get update && apt-get upgrade -y never running again, which is the actual reason images go stale. For COPY, the key is content and mode, and the results go both ways:
| Change made to the build | Cache result | Image digest |
|---|---|---|
| (baseline) | — | 19bb8519 |
| nothing at all | every step CACHED | 19bb8519 — identical |
touch data.txt (mtime) | every step CACHED | 19bb8519 — identical |
chmod 600 data.txt (mode) | COPY data.txt MISS | 8629de5a — changed |
edit other.txt (last COPY) | only last COPY MISS | 0513cd35 |
edit data.txt (earlier COPY) | both COPYs MISS | cecd49ed |
| add an uncopied file to the context | every step CACHED | cecd49ed — identical |
touch does not bust the cache; chmod does. Nearly every folk remedy for a stale build is touch.
And the inputs are recorded nowhere. Not the tag the base image had, not the package versions a RUN fetched, not the untracked files that were in the context — unless you turned provenance on, in which case they are in an attestation attached to a digest your pull did not fetch, per stage 5.
There is one fix on this page that is provable, and it is two flags. With SOURCE_DATE_EPOCH set and the exporter option rewrite-timestamp=true, two builds produced byte-identical digests:
c => sha256:8b05983100c2dfdbd7c5bea889af3bf61f44fd57f9acb2032ef328e3e6703167
d => sha256:8b05983100c2dfdbd7c5bea889af3bf61f44fd57f9acb2032ef328e3e6703167SOURCE_DATE_EPOCH support landed in BuildKit 0.13; rewrite-timestamp=true is the exporter option that makes it reach the layers. Verify: build the same thing twice with no changes and compare the two digests. If they differ, you cannot reproduce your own images, and the artefact in the registry is the only copy of what you shipped.
A worked diagnosis: the air-gapped mirror that stopped verifying
A team pins every image by digest, signs each one, and runs an admission controller that refuses anything unsigned. Production is air-gapped, so releases cross on a removable disk: the image is saved to a file on one side, loaded and pushed on the other. It has worked for a year.
Then admission starts refusing a release, and every obvious hypothesis is wrong in the same interesting way. The signature is present in the source registry and verifies there. The key has not rotated. The image is not tampered with, and they can prove it: crane config returns an identical config on both sides, the layer digests match one for one, the diff_ids match, and the container behaves identically. Every property anybody thinks of comparing is equal.
The hinge names the stage in one command. On the internal registry:
$ docker buildx imagetools inspect internal.registry/svc@sha256:c86a09aa...
ERROR: internal.registry/svc@sha256:c86a09aa...: not foundand against the tag rather than the digest, the same command returns a Digest: that is not the one in the deployment file. That is the hinge’s stage 3 branch and its stage 6 branch at once — except that nobody moved the tag and nobody deleted anything. Which is what makes it neither: the reference changed in transit.
Three commands finish it, and each removes a hypothesis. A sorted jq diff of the two manifests: empty. A sorted jq diff of the two configs: empty. And then the bytes, as the two registries actually serve them:
$ crane manifest source.registry/svc:2.4 | sha256sum
c86a09aa...
$ crane manifest internal.registry/svc:2.4 | sha256sum
f1c45587...The tarball round trip re-serialised the manifest. The OCI specification only recommends canonical JSON, so a tool that parses a manifest and writes it out again is free to produce a document that means the same thing and hashes differently — a space, a key order. The signature was made over the digest of the first document. Nothing was tampered with and nothing verifies.
The fix is not to re-sign on the far side. That would mean the air-gapped registry’s contents are attested by whoever operates the air-gapped registry, which is the one thing the whole arrangement exists to avoid.
And the honest fix is narrower than the advice usually given. In the matrix in stage 4, every route that preserved the digest was a direct registry-to-registry copy — skopeo, crane, regctl. Every route that went through a local store or a file changed it, including skopeo copy into an oci: layout. An air gap crossed by a file is an air gap that changes image identities, so the arrangement has to account for it: carry a registry across rather than a tarball, or accept the new digest at the far end and verify the image by the things that did survive the trip — the layer digests and the config, which matched exactly.
Three things generalise. The hinge did its work by naming which party changed the reference, not by knowing anything about the image. Any tool that parses and re-emits a manifest changes the image’s identity, and almost none of them says so. And the moral this page has been building towards: an image digest hashes the paperwork, not the goods. The same filesystem can arrive under two digests, and one digest can arrive as two different filesystems — which is why “pinned by digest” is a statement about a document rather than about software.
Symptoms, and which stage they belong to
Numbered by the spine at the top of the page, not by the order of the sections — they are the same here, which is deliberate.
| Symptom | Stage | What is actually true |
|---|---|---|
A secret is in the image and it was never in a COPY you wrote | 1 | COPY . /app shipped .git; the file is in the history |
.env is in .dockerignore and the password leaked anyway | 1 | The file was excluded; the commit that added it was not |
| A deleted file is extractable from the registry | 2 | The whiteout hides it at runtime and does not remove the layer |
A token appears in docker history | 2 | --build-arg is written into the config’s history verbatim |
| A multi-stage build and the credential still ships | 2 | It is an ENV, so it is in the config blob, which is not a layer |
| Same tag, two hosts, different behaviour | 3 | The tag moved; both digests are still pullable |
| A cleanup script removed far more than expected | 3 | DELETE by digest removes the manifest and every tag on it |
| Garbage collection reports nothing to collect | 3 | The tag retains a link to every digest it has ever named |
| The digest changed and nobody rebuilt or re-tagged anything | 4 | A save-and-load or pull-and-push route re-serialised the manifest |
exec format error, but only on some nodes | 4 | You pinned an index; the client chose the platform |
| Admission rejects an image your pipeline signed | 4 | One side used the index digest, the other the platform digest |
cosign verify finds nothing and the image pulls fine | 5 | The signature is a separate object and can be deleted alone |
| The SBOM is missing after mirroring | 5 | skopeo copy without --all took one platform |
| Provenance exists and nothing has ever read it | 5 | It is a unknown/unknown manifest no platform matcher selects |
ImagePullBackOff on a replaced node, weeks after a cleanup | 6 | Blob custody is per-repository; the manifest still returns 200 |
| The image inspects clean and will not pull | 6 | Manifest-level tools never request a blob |
| Two builds of the same commit give different digests | 7 | Timestamps are in the config and in the tar headers |
| A base image CVE is fixed upstream and your rebuild still has it | 7 | The RUN cache is keyed on the instruction text |
Advice that has expired
| Still repeated | What changed |
|---|---|
“DOCKER_CONTENT_TRUST=1 signs my images” | Docker has published a retirement schedule for Content Trust and Notary v1, with full shutdown announced for 8 December 2026; the named replacements are Sigstore/cosign and Notation |
“Use --build-arg for build-time secrets” | RUN --mount=type=secret has existed since Docker 18.09, November 2018, and unlike --build-arg it leaves no trace |
“docker build and docker buildx build produce the same image” | buildx has attached provenance by default since v0.10, January 2023, which turns the output into an index |
| “Any image fits in the local image store” | The classic store cannot hold an index, so it cannot hold an attested image; Docker Engine 29 makes the containerd store the default on new installations |
“Use docker manifest and manifest lists” | The spec’s word is index; a page still saying “manifest list” predates 2021 |
| “The Referrers API is how signatures are discovered” | The reference registry returns 404 for it and clients fall back to a tag scheme |
“docker sbom will generate one” | Discontinued; superseded by docker scout sbom |
| “A multi-architecture pull counts as one pull” | Docker’s documentation states it counts once per architecture |
How to tell whether a page about container images is worth reading
Almost every error above comes out of one sentence:
The corpus models a container image as a filesystem with a version number, and the digest as that version number.
It is seductive because it is almost right, it is right often enough to survive years of use, and every tool reinforces it — docker images prints a repository, a tag and a size, in a table, like a package list. Watch it generate the rest. A filesystem has no history, so a deleted file is deleted — stage 2. A version number names one thing, so one digest is one image — stage 4. A version number survives copying, so a digest survives being moved — stage 4 again. A filesystem carries nothing but files, so nothing ships except files — which is why every article about keeping secrets out of an image is about layers and none of them is about the config. A version number is issued by an authority, so somebody is keeping the image — stage 6. And the structural one: a version number is the end of the story, so no page has an ending. That is why almost everything written about container images stops at docker push.
The absences worth noticing. No mention of crane config or docker history means the author has only looked at images from the outside. No mention of the config blob at all — only “layers” and “the image” — means everything the page says about removing something is wrong for ENV. .dockerignore recommended for secrets with no mention of .git means the author has never extracted a published image and read it. “Pin by digest” with no mention of retention means they have never run a registry. Signing discussed with no mention of who verifies means they have signed an image and never checked one.
The one-question test, and it takes ten seconds. Search the page for the word config. If every occurrence refers to a configuration file of the reader’s own — a compose file, a daemon setting, a Dockerfile — and none refers to the image’s own config blob, the author has only ever thought of an image as a filesystem, and everything the page says about removing something from an image is wrong.
The test has a trap of its own, which is worth naming because it is part of the same error: both documents go by that name, and pages that get the mechanism right still use one name for both. If an image were a filesystem with a version number there would be no reason for it to carry a document about itself, so nobody expects there to be two.
552 passes this test — it names the config blob correctly, in the right place, and describes what it holds. It is on the right side of the line and it still stopped one clause short on the consequences, which is a fair picture of how much of this is a reading problem rather than a knowledge problem.
Related reading
- Building a container image — the page before this one: what the builder actually did with your Dockerfile, and why a green build log is a statement about the builder rather than about the image.
- Containers, All the Way Down — what happens to an image once it is on the host.
- Docker, the basics — where most people’s mental model of a tag comes from.
- Containerising a service — adopting a published image, UID first.
- The Life of a Package — the same custody questions, asked about a
.deb. - Kubernetes, Honestly — where admission control lives, and therefore where verification happens.
- The Life of a System Call — for the same habit of asking which party actually answered.
