Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

By updated

Improve this page

Logo Docker

Your Dockerfile starts with FROM alpine:3.24.3, and nothing guarantees that this tag will designate tomorrow the content it designates today. This lesson, for readers who already know how to read the identity of an image and publish to a registry, shows how to write the exact digest of the base in the FROM line, how to know that a newer version has been released, then how to deliver the finished image to a machine that reaches no public registry. Two routes are measured: the archive, produced by docker save, and a registry whose volume is transported. The outputs come from Docker Engine 29.9.2 with Buildx 0.38.1, on Debian 13 and the containerd image store.

Several names in this lesson are French because the commands and outputs are the ones that were run: menteur/ ("liar"), socle-maison ("home-made base"), livraison-vol and registre-livraison ("delivery" volume and registry), verifie-socle.sh ("check base"), machine-isolee ("isolated machine"), registre-data.tar, alteree.tar ("tampered"), deux.tar ("two"), transfert.sha256 ("transfer"), miroir, miroir-nu, miroir-connecte and miroir-cache ("mirror", "bare", "connected" and "cache"). The messages printed by the verifie-socle.sh script are French for the same reason. They are identifiers and outputs, so they stay as they are. The comments in the code are in English.

What you will learn

  • Read the index digest of a tag and write it in a FROM name:tag@sha256:… line
  • Prove that a pinned build stays on the old base when the tag is moved
  • Detect with a script that a newer base has been published
  • Export an image with docker save, know what --platform changes to the identity of the loaded image, and verify the archive with sha256sum before loading it
  • Deliver an image through a registry whose volume is transported, and pull it by digest without a network
  • Explain why a pull-through mirror is not enough offline, and diagnose a failed delivery

Prerequisites

  • Read an OCI image and its identity: tag, index, manifest and digest. The lesson does not define them again.
  • Choose a base image: why the tag of a base is mutable and how to follow a base over time.
  • Publish and pull an image in a controlled way: docker push, docker pull and the local registry over HTTP on 127.0.0.1.
  • Serve a trusted registry, and log in to it: the previous lesson sets up a registry with TLS and a password. Here the registry stays in HTTP on the loopback interface: that is enough for the demonstration, not for a registry that other machines reach.
  • A Docker Engine 29.9.2 with Buildx (shipped with it) on an amd64 host, and the ability to start a privileged container to simulate the machine with no network. The sizes and manifest digests in the lesson are those of an amd64 host.
  • python3, which the lesson uses to read a token and to tamper with an archive on purpose.

The FROM lines of the lesson carry their digest, and so do the commands that run alpine:3.24.3 or busybox:1.37, in the form image:tag@sha256:…. Only the commands that prove the absence of an image keep the bare tag: the block concerned carries a # ❌ comment, because those commands must fail. The digests displayed are those of the measurement: those of an image that you build yourself differ from ours, which is normal.

Three behaviors are not guaranteed, because the measurement did not replay them: automatic updates by Renovate, loading a multi-architecture archive, and an image store other than containerd. The lesson points them out in passing. Copying a complete index to a registry with skopeo copy --all is not replayed in this lesson: the page BuildKit cache in CI and image promotion between registries measures it, and the Skopeo guide measures --all towards an archive.

How do you read the digest of a tag?

To pin a base, you first have to read its digest. The command to remember is docker buildx imagetools inspect: it queries the registry without pulling the image and, without options, displays the digest of the index, the list of the variants per architecture.

Terminal window
docker buildx imagetools inspect alpine:3.24.3 | head -12
Name: docker.io/library/alpine:3.24.3
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
Manifests:
Name: docker.io/library/alpine:3.24.3@sha256:c26612752c78b18faacd63c7d50f4592c2e7b0f25e0e20e82bd74e465c57ab0d
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Annotations:
org.opencontainers.image.source: https://github.com/alpinelinux/docker-alpine.git#5aec467fb81238bae9441eea2edb000655d7044f:x86_64
org.opencontainers.image.url: https://hub.docker.com/_/alpine
org.opencontainers.image.version: 3.24.3

Look at the Digest line at the top: it is the one of the index, and it is the one you pin, not the one of the first Name entry, which is the linux/amd64 manifest. Pinning the index keeps the build portable from one architecture to another.

To print only the digest, use --format. The output has no final line break, which suits a variable assignment.

Terminal window
docker buildx imagetools inspect alpine:3.24.3 --format '{{.Manifest.Digest}}'
sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a

At the time of the measurement, alpine:3.24.3 and alpine:latest returned the same digest, and alpine:3.22 another one (sha256:2e0b81d6…). Another command, docker manifest inspect, still exists, but it lists the manifests of the index without displaying the digest of the index itself: it is not suited to reading the digest to pin.

How do you find that digest through the registry API?

This subsection is optional. The registry API answers the same question as imagetools: Docker Hub requires a token, even an anonymous one, then you query the manifest of the tag with an Accept header that asks for the index. The docker-content-digest header of the response carries the digest. The dialogue with an authenticated registry is detailed in the lesson on the trusted registry.

Terminal window
TOK=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull" | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
curl -sI -H "Authorization: Bearer $TOK" \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
https://registry-1.docker.io/v2/library/alpine/manifests/3.24.3 \
| grep -i -E 'docker-content-digest|^HTTP'
HTTP/2 200
docker-content-digest: sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a

The digest is the same as the one from imagetools. Do not run these commands with set -x: the shell would display the Bearer token.

How do you pin a base in the FROM line?

The form to write is FROM name:tag@sha256:<index digest>. Docker then builds on the content designated by the digest. The tag only serves the human who reads the file: it says which version is meant.

FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN cat /etc/alpine-release

A trap hides in this form: when a digest is present, Docker ignores the tag, and it does not warn you. To see it, write a wrong tag, 3.22, in front of the digest of 3.24.3:

FROM alpine:3.22@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN cat /etc/alpine-release

Put this Dockerfile in a menteur/ folder, like the others in the lesson, and build it by designating that folder.

Terminal window
docker build --progress=plain --no-cache -t t:menteur menteur
#4 [1/2] FROM docker.io/library/alpine:3.22@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
#4 resolve docker.io/library/alpine:3.22@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a 0.0s done
#5 [2/2] RUN cat /etc/alpine-release
#5 0.151 3.24.3

The build succeeds, and the RUN line displays 3.24.3: the digest decided. The file, for its part, keeps announcing 3.22. A tag that does not match the digest misleads the next reader without any tool flagging it. After each pinning, check that the tag you wrote is indeed the one whose digest you read.

A digest that does not exist is not ignored, on the other hand. Docker stops the build and says so:

ERROR: failed to build: failed to solve: alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000: failed to resolve source metadata for docker.io/library/alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000: docker.io/library/alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000: not found

The message reads from right to left: not found is the answer of the registry, failed to resolve source metadata says that the base could not be resolved, and the build error follows from it. A typo in the digest is therefore spotted right away.

What does a pinned build keep when the tag is moved?

A pinned build stays on the old content, even if the tag now designates something else. To prove it without waiting for a public registry to move, you need a tag that you move yourself. A local registry on the loopback interface is enough, as in the lesson on Docker Hub: the address 127.0.0.1 is accepted in HTTP without any setting.

Terminal window
docker volume create livraison-vol
docker run -d --name registre-livraison -p 127.0.0.1:5001:5000 -v livraison-vol:/var/lib/registry registry:3.1.2@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3

Build a first home-made base, named socle-maison:2026-11, that adds a file /v containing version-1. Put this Dockerfile in a v1/ folder, the next one in v2/ and the one of the application in app/. Push the base, then read its digest into the OLD variable.

FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN echo version-1 > /v
Terminal window
docker build -q -t 127.0.0.1:5001/socle-maison:2026-11 v1 >/dev/null && docker push 127.0.0.1:5001/socle-maison:2026-11 | tail -2
OLD=$(docker buildx imagetools inspect 127.0.0.1:5001/socle-maison:2026-11 --format '{{.Manifest.Digest}}'); echo "OLD=$OLD"
f27d9101980d: Pushed
2026-11: digest: sha256:68dc2aea13813b3242bdff68332dd2e6c9dc136b15298d22a461e48b6ca112fe size: 855
OLD=sha256:68dc2aea13813b3242bdff68332dd2e6c9dc136b15298d22a461e48b6ca112fe

Do the same again with a second Dockerfile, in v2/, that writes version-2 in /v, under the same tag: that is the move. The registry accepts the push, and the tag now points to another digest, which you read into NEW.

Terminal window
docker build -q -t 127.0.0.1:5001/socle-maison:2026-11 v2 >/dev/null && docker push 127.0.0.1:5001/socle-maison:2026-11 | tail -2
NEW=$(docker buildx imagetools inspect 127.0.0.1:5001/socle-maison:2026-11 --format '{{.Manifest.Digest}}'); echo "NEW=$NEW"
7b229517ed22: Layer already exists
2026-11: digest: sha256:6e8876d4f66de01da0b1744bfc0878f8993c858f115f6f4fe84fe42a0fe98772 size: 855
NEW=sha256:6e8876d4f66de01da0b1744bfc0878f8993c858f115f6f4fe84fe42a0fe98772

Now write the application on the base, with the digest of the old content, delete the local image of the base to force the resolution, then build while asking the engine to check the registry again (--pull) and to reuse nothing (--no-cache).

FROM 127.0.0.1:5001/socle-maison:2026-11@sha256:68dc2aea13813b3242bdff68332dd2e6c9dc136b15298d22a461e48b6ca112fe
RUN cat /v > /vu
CMD ["cat","/vu"]
Terminal window
docker image rm 127.0.0.1:5001/socle-maison:2026-11
docker build --pull --no-cache -t demo-web:1.0 app 2>&1 | grep -E 'FROM|ERROR|resolve'
docker run --rm demo-web:1.0
#4 [1/2] FROM 127.0.0.1:5001/socle-maison:2026-11@sha256:68dc2aea13813b3242bdff68332dd2e6c9dc136b15298d22a461e48b6ca112fe
#4 resolve 127.0.0.1:5001/socle-maison:2026-11@sha256:68dc2aea13813b3242bdff68332dd2e6c9dc136b15298d22a461e48b6ca112fe 0.0s done
version-1

Look at the last line: the container displays version-1, although the tag designates version-2. The build stayed on the pinned base. The registry still serves the old digest: a request on manifests/<old digest> answers 200. Pinning guarantees the content, not its availability: if the registry ever stops serving this digest, the build fails with the not found error seen above, and that is preferable to content that changes silently.

How do you know a newer base has been published?

A pinned base ages, and that is intended: you decide when to move up. You only need something to warn you that moving up is possible. A script of a few lines compares the digest pinned in the Dockerfile with the digest that the registry now serves for the tag, with no third-party tool. Save it under the name verifie-socle.sh.

#!/usr/bin/env bash
# Compare the digest pinned in the Dockerfile with the digest the registry serves for the tag.
set -euo pipefail
ref=$(awk '/^FROM /{print $2; exit}' "$1")
name=${ref%@*}; pinned=${ref#*@}
served=$(docker buildx imagetools inspect "$name" --format '{{.Manifest.Digest}}')
if [ "$served" = "$pinned" ]; then echo "à jour : $name"; else echo "SOCLE PLUS RÉCENT : $name sert $served, épinglé $pinned"; exit 1; fi

The script reads the first FROM line of the file passed as an argument. It splits the reference in two with ${ref%@*}, which keeps the name and the tag, and ${ref#*@}, which keeps the digest. Then it asks the registry which digest it serves for name:tag.

That is the reason to keep the tag in the FROM: without it, the script would have no name to query, and the pinned digest would say nothing about what comes next on the branch.

The script has limits. It reads only the first FROM line: a multi-stage Dockerfile needs one script per base. A FROM --platform=... makes it read the option instead of the reference. And an unreachable registry, or a pull limit reached, also returns rc=1: that code says "the check failed", not necessarily "a newer base exists". Read the message before concluding.

Applied to the application of the previous section, whose tag has been moved:

Terminal window
bash verifie-socle.sh app/Dockerfile; echo "rc=$?"
SOCLE PLUS RÉCENT : 127.0.0.1:5001/socle-maison:2026-11 sert sha256:6e8876d4f66de01da0b1744bfc0878f8993c858f115f6f4fe84fe42a0fe98772, épinglé sha256:68dc2aea13813b3242bdff68332dd2e6c9dc136b15298d22a461e48b6ca112fe
rc=1

The message names the two digests, and the exit code is 1. It is the code that makes a pipeline step fail: a scheduled task that runs the script stops being green as soon as a newer base is published, and nobody needs to read a log to know it.

The script does not say whether the new version is good for you, it only says that the base has moved; the decision to move up, with the checks of the application, is the one covered by the lesson on choosing a base.

Tools automate the same watch and open the update request for you. The guide on Renovate and the page on maintaining pinned references at scale cover them. According to its documentation, Renovate pins by digest: it keeps the tag in the FROM line and proposes an update when the content of the tag changes.

A frozen digest receives no security fix: the pinned image keeps its vulnerabilities as long as you do not move the base up. Pinning makes the update deliberate, it does not remove it, and the detection script above is there so that no base ages without someone having decided it.

Archive or registry: which route to deliver offline?

A machine with no access to the Internet cannot run docker pull against Docker Hub. The image must therefore be brought to it by another path, physical or internal. The transfer medium (USB stick, internal network share) changes from one site to another; the lesson is interested in what you transport. Two routes exist, and they differ on that point.

The diagram below shows the two chains side by side. Look at the two boxes marked sha256sum -c: in both routes, the checksum is verified before any loading, and the receipt travels by a channel separate from the archives. The left route transports one file that docker load consumes, produced by docker save. The right route transports a whole registry, its image and its volume, which you restart on the isolated machine before pulling the image there by its digest.

Two chains from a connected machine to an isolated machine. Route 1, the archive: docker save writes an archive and its sha256sum receipt, the archive is transferred and the receipt arrives by a separate channel, sha256sum -c verifies the archive, then docker load loads it. Route 2, the transported registry: docker push sends the image into a local registry, the registry is stopped and its volume and its image are archived, the archives are transferred and their receipt arrives by a separate channel, sha256sum -c verifies them, the registry image is loaded, the volume restored, the registry restarted and the image pulled by digest. Both routes end with docker run with --pull never on the isolated machine.Route 2: the carried registryRoute 1: the archiveConnected machineimage built or pulleddocker save, thensha256sum writes the receiptTransfer the .tar;receipt by a separate channelsha256sum -cbefore any loaddocker loaddocker pushto a local registryStop the registry,archive its volumeand the registry imageTransfer the archives;receipt by a separate channelsha256sum -cbefore any loaddocker load the registry image,restore the volume, restart the registrydocker pull name@digestIsolated machinedocker run --pull never
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The archive is the shortest route when you deliver one or two images to a machine. The transported registry takes more steps, and it suits the case where several isolated machines pull the same images, or where a tool expects a registry reference rather than a file.

How do you simulate a machine with no network?

To verify a delivery, you need a machine that truly cannot reach anything. If you have none at hand, a Docker-in-Docker container started with --network none plays the part. It is a demonstration device, not a delivery method: the --privileged option gives this container almost all the rights over the host machine, and it is only set on a test workstation.

Terminal window
mkdir -p xfer
docker run -d --name machine-isolee --privileged --network none \
-e DOCKER_TLS_CERTDIR= -v "$PWD/xfer:/xfer:ro" \
docker:dind@sha256:9e79b879b177fbd72c34376a3dd3d91747da5db49e1f560f76aea59561471977
until docker exec machine-isolee docker info >/dev/null 2>&1; do sleep 2; done
docker exec machine-isolee docker version --format 'server {{.Server.Version}}'
docker exec machine-isolee docker info --format 'driver={{.Driver}} status={{.DriverStatus}} images={{.Images}}'
server 29.9.2
driver=overlayfs status=[[driver-type io.containerd.snapshotter.v1]] images=0

The until loop waits for the inner daemon to answer: it needs about 18 seconds after the container starts, and a docker exec … docker version launched right away fails with failed to connect to the docker API. The -e DOCKER_TLS_CERTDIR= option disables TLS between the client and the inner daemon: harmless here, since --network none shuts the container off from the network, and it avoids managing certificates. The xfer folder of the host is mounted read-only: it plays the transfer medium. The inner daemon is also a Docker 29.9.2 on the containerd store, with zero images.

Now check that the isolation is real, by asking this container to reach Docker Hub:

Terminal window
# ❌ By tag, on purpose: these three commands prove the absence of the image, not its identity
docker exec machine-isolee wget -T 3 -qO- https://registry-1.docker.io/v2/
docker exec machine-isolee docker pull alpine:3.24.3
docker exec machine-isolee docker run --pull never --rm alpine:3.24.3 true
wget: bad address 'registry-1.docker.io'
Error response from daemon: failed to resolve reference "docker.io/library/alpine:3.24.3": failed to do request: Head "https://registry-1.docker.io/v2/library/alpine/manifests/3.24.3": dial tcp: lookup registry-1.docker.io on 10.10.114.1:53: dial udp 10.10.114.1:53: connect: network is unreachable
docker: Error response from daemon: No such image: alpine:3.24.3
Run 'docker run --help' for more information

Three messages can be read here. bad address says that the name of the registry does not even resolve. The error of the pull ends with network is unreachable: it is the expected failure offline, the same by tag and by digest.

The third one changes the nature of the failure. With --pull never, Docker no longer looks for a registry and answers No such image, because the image is not in the local store. This command serves as proof: if it works after the loading, the image has indeed arrived.

For the rest of the lesson, the commands prefixed with docker exec machine-isolee run on the isolated machine. On a real machine, remove this prefix.

How do you export an image with docker save?

docker save writes a tar archive of the image, with its layers and its metadata. Pull the image by its digest, give it back its tag with docker tag, then export it: -o names the output file. The docker tag is not decorative, because a pull by digest does not set the tag, and the section on docker pull tag@digest shows further down what happens without it.

Terminal window
docker pull alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
docker tag alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a alpine:3.24.3
docker save -o xfer/alpine.tar alpine:3.24.3
ls -l xfer/alpine.tar
tar tf xfer/alpine.tar
-rw------- 1 ansible ansible 3961856 Nov 3 19:03 xfer/alpine.tar
blobs/
blobs/sha256/
blobs/sha256/3da1513d2124248e6e2a526d532ff365833915ce1abe0813bfc5344190827406
blobs/sha256/3ec36f3670df56d1b1d4323910cf6747576cb31e5e5d32c64715339876d86f20
blobs/sha256/7b229517ed222597277036d94892173207d01a731ab3a88eeb1de2e963a2c788
blobs/sha256/964745728d174226819257152a79b34561fe7321472eb9c8e32f461cbc7969c2
blobs/sha256/b92841763c5c2140a20eeb2a44e94ebb0e80fd51d9d6b5895ea72fa999525d68
blobs/sha256/bcd0699075b7694fe6061b0cf0bc8134b3f900f8d7456021c06b7cbbef460f8b
blobs/sha256/be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
blobs/sha256/c26612752c78b18faacd63c7d50f4592c2e7b0f25e0e20e82bd74e465c57ab0d
index.json
manifest.json
oci-layout

The archive weighs about 4 MB, and two things can be read in this output. The permissions are -rw-------: only the account that created the archive can read it, so another account in charge of the transfer will first have to obtain the rights. The blobs/sha256/ folder contains files named after their digest, among them be2656…, the index, and c26612…, the amd64 manifest: the archive embeds the same structure as the registry.

To deliver several images at once, docker save accepts several names in the same command. The next section does it with alpine:3.24.3 and busybox:1.37, once busybox:1.37 is pulled: the deux.tar archive weighs 6,192,640 bytes, roughly the sum of the two separate archives, and it loads in a single command on the isolated machine.

Why does docker pull tag@digest not set the tag?

If you pull an image by tag and by digest (docker pull busybox:1.37@sha256:…), Docker fetches the content but does not create the tag in the local store. The tag is ignored, exactly as in a FROM: only the digest counts. The trap shows up at export time.

Terminal window
docker pull -q busybox:1.37@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec
docker images --digests busybox
docker save -o xfer/bb.tar busybox:1.37
docker.io/library/busybox:1.37@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
busybox <none> sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec a7dbc319b143 2 years ago 6.77MB
Error response from daemon: No such image: busybox:1.37

The TAG column displays <none>: the image is there, but without a name. docker save busybox:1.37 therefore fails with No such image, which is surprising when you have just pulled it. The remedy takes one command, which sets the tag on the image already present:

Terminal window
docker tag busybox@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec busybox:1.37
docker save -o xfer/deux.tar alpine:3.24.3 busybox:1.37
docker save -o xfer/bb.tar busybox:1.37

The bb.tar archive contains only busybox:1.37: the section on the receipt uses it. The same reflex applies to the registry image of the registry route: pulled by digest, it must be tagged again before being exported.

Which platform does docker save export?

docker save without options exports all the platforms whose content is present in the local store, not all those that the index announces. This behavior is the one described by the documentation of docker image save. On an amd64 host, a docker pull busybox:1.37@sha256:… downloads only the amd64 variant: the archive from docker save -o xfer/bb.tar busybox:1.37 then weighs 2,235,392 bytes, and its index.json describes the whole index although only the amd64 manifest is in the archive.

Next, pull the other variant, then export to a file distinct from bb.tar, which the section on the receipt reuses.

Terminal window
docker pull -q --platform linux/arm64 busybox@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec
docker save -o xfer/bb-multi.tar busybox:1.37
ls -l xfer/bb-multi.tar

The same command then produces 4,144,640 bytes, with the amd64 and arm64 manifests, whether you export the tag or the busybox@sha256:… reference.

The --platform option changes what is exported, and it also changes the identity of the loaded image. Without it, the archive keeps the index: its index.json designates the index (be2656… for alpine:3.24.3), exactly as in the archive of the previous section. With --platform, the archive is reduced to a single manifest: its index.json designates the c26612… manifest, and the blob of the be2656… index is no longer in it.

Terminal window
docker save --platform linux/amd64 -o xfer/alpine-amd64.tar alpine:3.24.3
docker save --platform linux/arm64 -o xfer/alpine-arm64.tar alpine:3.24.3
Error response from daemon: no suitable export target found: image with reference alpine:3.24.3 was found but does not provide the specified platform (linux/arm64)

The first command succeeds (3,952,640 bytes, against 3,961,856 for the archive without options). The second fails because the arm64 variant of alpine has never been downloaded on this machine: --platform chooses among what the local store holds, it does not go and fetch it. After a docker pull --platform linux/arm64 of busybox, docker save --platform linux/arm64 -o xfer/bb-arm.tar busybox:1.37 works and produces an archive of 1,915,392 bytes.

An archive made with --platform therefore resembles what a registry receives after a docker push of a single platform: an image whose digest is the one of the manifest, not the one of the index. The next section shows what this changes on the isolated machine.

Keep the consequence for a delivery in mind. Know which architecture the isolated machine is, and export without --platform if you pin your FROM lines on the index digest: the archive keeps the index, and exports the platforms present in the store. An archive that covers several architectures is obtained by pulling each variant before the export. Export with --platform only to shrink the archive, and accept then that the loaded image carries the digest of the manifest. The shrinking is worthwhile only if the local store holds several platforms: with only the amd64 platform of alpine, the difference is just 9,216 bytes (3,952,640 against 3,961,856). Loading a multi-architecture archive was not replayed in this lesson. Building the variants is covered by the lesson on multi-architecture.

What does docker load preserve on the isolated machine?

On the isolated machine, you first compare the checksum of the archive with the one that left, then you load it. In the measurement, sha256sum xfer/alpine.tar on the connected machine and sha256sum /xfer/alpine.tar on the isolated machine give the same checksum, 6fdb2c195eee3b0c1ccf5198c506c3c1ec8a360529c374849d6cc655404b191c. Here the comparison is done by eye; the section on verifying the archive automates it with a receipt and sha256sum -c.

Terminal window
docker exec machine-isolee sha256sum /xfer/alpine.tar
docker exec machine-isolee docker load -i /xfer/alpine.tar
docker exec machine-isolee docker images --digests
docker exec machine-isolee docker image inspect alpine:3.24.3 --format 'Id={{.Id}} RepoDigests={{.RepoDigests}}'
docker exec machine-isolee docker run --rm --pull never alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a cat /etc/alpine-release
docker exec machine-isolee docker run --rm --pull never alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a cat /etc/alpine-release
6fdb2c195eee3b0c1ccf5198c506c3c1ec8a360529c374849d6cc655404b191c /xfer/alpine.tar
Loaded image: alpine:3.24.3
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
alpine 3.24.3 sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a be2656061b53 12 days ago 13MB
Id=sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a RepoDigests=[alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a]
3.24.3
3.24.3

Look at three points. The tag arrives with the image: Loaded image names it. The Id is the digest of the index, the one you had pinned, and RepoDigests confirms it: on this containerd store, loading an archive made without --platform does not change the identity. Finally, the image runs with --pull never, so with no network access at all, by its pinned tag (tag@sha256:…) as well as by its digest alone.

A locally built image behaves the same: its Id, its RepoDigests and its layers (RootFS.Layers) are identical before docker save and after docker load. This preserved identity holds for the containerd store of the lesson, and for an archive made without --platform. The lesson on exporting without a daemon with Skopeo shows a route that changes this identity.

What becomes of the identity of an archive made with --platform?

Load the alpine-amd64.tar archive of the previous section on the isolated machine, on top of the image from alpine.tar already loaded: both carry the name alpine:3.24.3, so the new archive replaces the existing image (behavior measured with a Skopeo archive in the lesson on exporting without a daemon with Skopeo; the output below comes from an empty machine). Then read the identity of the image and try to run it by the index digest.

Terminal window
docker exec machine-isolee docker load -i /xfer/alpine-amd64.tar
docker exec machine-isolee docker image inspect alpine:3.24.3 --format 'Id={{.Id}} RepoDigests={{.RepoDigests}}'
docker exec machine-isolee docker run --rm --pull never alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a cat /etc/alpine-release
docker exec machine-isolee docker run --rm --pull never alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a cat /etc/alpine-release
Loaded image: alpine:3.24.3
Id=sha256:c26612752c78b18faacd63c7d50f4592c2e7b0f25e0e20e82bd74e465c57ab0d RepoDigests=[alpine@sha256:c26612752c78b18faacd63c7d50f4592c2e7b0f25e0e20e82bd74e465c57ab0d]
docker: Error response from daemon: No such image: alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
Run 'docker run --help' for more information
docker: Error response from daemon: No such image: alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
Run 'docker run --help' for more information

Look at the Id: it is the digest of the amd64 manifest (c26612…), not the one of the index (be2656…). Both launches by the index digest fail with No such image (code 125), although the image is indeed loaded: it simply carries another digest.

A build whose FROM is pinned on the index fails the same way, with an error that looks like a network outage, because Docker then looks for the index digest on the registry:

#2 ERROR: failed to do request: Head "https://registry-1.docker.io/v2/library/alpine/manifests/sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a": dial tcp: lookup registry-1.docker.io on 10.10.114.1:53: dial udp 10.10.114.1:53: connect: network is unreachable

With an archive made without --platform (alpine.tar), the same build succeeds, like the two --pull never launches. The choice therefore boils down to this: without --platform, the FROM pinned on the index resolves; with --platform, you must pin the FROM on the manifest digest, the one that the first Name entry of docker buildx imagetools inspect displays, and whose content the archive carries.

Loading the --platform archive therefore replaced the image that came from alpine.tar: an archive loaded under a name already present takes the place of the one that carried it. The sections that follow rely on the index digest: remove this image and load the archive made without options again.

Terminal window
docker exec machine-isolee docker rmi -f alpine:3.24.3
docker exec machine-isolee docker load -i /xfer/alpine.tar
Untagged: alpine:3.24.3
Deleted: sha256:c26612752c78b18faacd63c7d50f4592c2e7b0f25e0e20e82bd74e465c57ab0d
Loaded image: alpine:3.24.3

How do you verify the archive before loading it?

The exit code of docker load does not prove that the archive is intact. An archive tampered with by a single byte, of the same size, loads with code 0, and the image appears in docker images. The documentation of docker image load describes no integrity check: the checksum, computed before departure and verified before loading, is the reliable control against a damaged archive (its limit against deliberate tampering is given further down).

On the connected machine, write the receipt: here in xfer, for the demonstration. On the isolated machine, verify it before any docker load.

Terminal window
(cd xfer && sha256sum bb.tar > bb.tar.sha256 && sha256sum -c bb.tar.sha256)
bb.tar: OK

To see what the verification catches, change one byte of a layer in a copy of the archive. The script extracts the archive, changes byte 1000 of a layer, then archives again. The extracted folder is read-only, hence the chmod. Since busybox is pulled by digest, the modified layer (68fe9bff…) carries the same name on your machine as in the measurement.

Terminal window
mkdir -p alt && cd alt && tar xf ../xfer/bb.tar && cd ..
chmod -R u+w alt
python3 - <<'PY'
p="alt/blobs/sha256/68fe9bff2ad4ad46da3e58db03faa64eb73c0ff16d546b0c42539eaa9854cb7f"
b=bytearray(open(p,"rb").read()); b[1000]^=0xff; open(p,"wb").write(b)
PY
(cd alt && tar cf ../xfer/alteree.tar .)
(cd xfer && cp bb.tar.sha256 alteree.tar.sha256 && sed -i 's/ bb.tar/ alteree.tar/' alteree.tar.sha256 && sha256sum -c alteree.tar.sha256; echo rc=$?)
alteree.tar: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match
rc=1

The checksum rejects the archive. Now see what docker load does on the isolated machine if you give it this archive anyway. The output shown assumes that busybox:1.37 has not yet been loaded on this machine, which is the case at this stage of the lesson, where only alpine has been loaded there; with busybox already present, the result was not measured.

Terminal window
docker exec machine-isolee docker load -i /xfer/alteree.tar; echo rc=$?
docker exec machine-isolee docker run --rm --pull never busybox:1.37@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec echo ok
Loaded image: busybox:1.37
Error unpacking image busybox:1.37: apply layer error for "docker.io/library/busybox:1.37": failed to extract layer sha256:5a9bfd996c6d49c6c3cb4ddfb76e8d09cf16b6b7cf6601d1f2dffbdbc3edb5d4: NotFound: failed to get reader from content store: content digest sha256:68fe9bff2ad4ad46da3e58db03faa64eb73c0ff16d546b0c42539eaa9854cb7f: not found
rc=0
docker: Error response from daemon: apply layer error for "": failed to extract layer sha256:5a9bfd996c6d49c6c3cb4ddfb76e8d09cf16b6b7cf6601d1f2dffbdbc3edb5d4: NotFound: failed to get reader from content store: content digest sha256:68fe9bff2ad4ad46da3e58db03faa64eb73c0ff16d546b0c42539eaa9854cb7f: not found
Run 'docker run --help' for more information

The loading displays Loaded image then an extraction error, and returns rc=0. The image is listed, but docker run fails. The content digest … not found message shows that the store refused the tampered blob: a digest-based check does exist, but its failure does not reach the exit code of docker load. The receipt is therefore still necessary, and it would have avoided discovering the problem on the production machine.

The receipt protects against a damaged archive. Against deliberate tampering, it has value only if it travels by a channel other than the archive itself: an attacker who changes the archive also changes a receipt stored next to it. For a guarantee of origin, signing the image goes further, as shown by the guide on Cosign.

Two other failures are recognized by their message. A truncated archive (here 2,000,000 bytes of an archive that was larger) is clearly refused, with a non-zero code:

failed to ingest "blobs/sha256/7b229517ed222597277036d94892173207d01a731ab3a88eeb1de2e963a2c788": short read: expected 3853109 bytes but got 1911424: unexpected EOF

And an archive of the wrong architecture loads without error. The failure comes at launch: docker run warns that the platform of the image (linux/arm64/v8) does not match that of the host (linux/amd64/v3), then the first program launched fails.

WARNING: The requested image's platform (linux/arm64/v8) does not match the detected host platform (linux/amd64/v3) and no specific platform was requested
exec /bin/uname: exec format error

exec format error means that the kernel does not know how to run this binary: go back to the exported platform.

How do you deliver through a transported registry?

The registry route transports a registry from the connected machine to the isolated machine: the image of the registry, and its volume, the folder where it stores the pushed images. Once restarted on the isolated machine, it serves the images like an ordinary registry, and you pull them by digest.

Start by feeding the registry of the connected machine. A docker push of an image of which only the local platform is present sends only that platform, and the registry then receives the digest of the amd64 manifest, not the one of the index. Push the copy of the base and the application, and read the digests received:

Terminal window
docker tag alpine:3.24.3 127.0.0.1:5001/reference/alpine:3.24.3
docker push 127.0.0.1:5001/reference/alpine:3.24.3 | tail -1
docker tag demo-web:1.0 127.0.0.1:5001/demo-web:1.0
docker push 127.0.0.1:5001/demo-web:1.0 | tail -1
curl -s http://127.0.0.1:5001/v2/_catalog
sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a -> sha256:c26612752c78b18faacd63c7d50f4592c2e7b0f25e0e20e82bd74e465c57ab0d
1.0: digest: sha256:afba17b4f22057ebb22ee737fb4ff0c6b3dfad0434ec348ea003bad9525fc9c5 size: 855
{"repositories":["demo-web","reference/alpine","socle-maison"]}

The first line says that the be26… index became the c266… manifest: the digest pinned on Docker Hub therefore does not exist in this registry, which knows only the amd64 manifest. A FROM pinned on the Hub index will not resolve on this copy. The locally built image, for its part, keeps its index (afba17b4…). To copy a complete index to a registry, skopeo copy --all copies the index and all its variants, whereas without it only the platform of the machine is copied. The page BuildKit cache in CI and image promotion between registries measures this copy to a registry; the Skopeo guide measures --all towards an archive.

Then transport the registry. Stop it first, so that its volume does not move during the copy. Archive the volume with a throwaway container, export the registry image, and write a receipt for both files.

Terminal window
docker stop registre-livraison
umask 077
docker run --rm -v livraison-vol:/v:ro alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a tar cf - -C /v . > xfer/registre-data.tar
docker tag registry@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3 registry:3.1.2
docker save -o xfer/registry-image.tar registry:3.1.2
(cd xfer && sha256sum registre-data.tar registry-image.tar > transfert.sha256 && cat transfert.sha256)

The volume weighs about 4 MB and the image of the registry about 21 MB. After the docker stop, docker ps -a shows registre-livraison as Exited (2): that is the exit code of the registry stopped by the signal, not a failure. The docker tag answers the pull tag@digest trap seen above: the registry image was pulled by digest, so without a tag.

The shell redirection > creates registre-data.tar with the permissions of your umask, often readable by the other accounts: the umask 077 placed just before the command reserves the file for your account, like the archive from docker save. It stays active in this terminal until it is closed. The transfert.sha256 receipt is stored in xfer for the demonstration, but it is transmitted apart from the archives, by another channel, otherwise it does not protect against deliberate tampering.

On the isolated machine, verify the checksums, load the registry image, restore the volume, then restart the registry. The container that restores the volume uses the alpine image: it must already be loaded on the isolated machine, which is the case after the docker load of alpine.tar above, since no pull is possible.

Terminal window
docker exec machine-isolee sh -c 'cd /xfer && sha256sum -c transfert.sha256'
docker exec machine-isolee docker load -i /xfer/registry-image.tar
docker exec machine-isolee docker volume create regvol
docker exec machine-isolee docker run --rm -v regvol:/v -v /xfer:/xfer:ro alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a tar xf /xfer/registre-data.tar -C /v
docker exec machine-isolee docker run -d --name registre-livraison -p 127.0.0.1:5000:5000 -v regvol:/var/lib/registry registry:3.1.2@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3
docker exec machine-isolee wget -qO- http://127.0.0.1:5000/v2/_catalog
docker exec machine-isolee wget -qO- http://127.0.0.1:5000/v2/demo-web/tags/list
registre-data.tar: OK
registry-image.tar: OK
Loaded image: registry:3.1.2
{"repositories":["demo-web","reference/alpine","socle-maison"]}
{"name":"demo-web","tags":["1.0"]}

The catalog is identical to that of the connected machine: the registry did pick up its content. All that remains is to pull the image by its digest and run it without a network. The --pull never is the proof here: it forbids any access, and the image must already be there.

Terminal window
docker exec machine-isolee docker pull 127.0.0.1:5000/demo-web@sha256:afba17b4f22057ebb22ee737fb4ff0c6b3dfad0434ec348ea003bad9525fc9c5 | tail -2
docker exec machine-isolee docker run --rm --network none --pull never 127.0.0.1:5000/demo-web@sha256:afba17b4f22057ebb22ee737fb4ff0c6b3dfad0434ec348ea003bad9525fc9c5
Status: Downloaded newer image for 127.0.0.1:5000/demo-web@sha256:afba17b4f22057ebb22ee737fb4ff0c6b3dfad0434ec348ea003bad9525fc9c5
127.0.0.1:5000/demo-web@sha256:afba17b4f22057ebb22ee737fb4ff0c6b3dfad0434ec348ea003bad9525fc9c5
version-1

The version-1 output comes from the container launched without a network, from an image pulled from a registry that did not exist on this machine before the volume was loaded. Two errors are recognized by their message. A digest that the registry does not have, or a repository that does not exist, answer not found:

Error response from daemon: failed to resolve reference "127.0.0.1:5000/demo-web@sha256:0000000000000000000000000000000000000000000000000000000000000000": 127.0.0.1:5000/demo-web@sha256:0000000000000000000000000000000000000000000000000000000000000000: not found
Error response from daemon: failed to resolve reference "127.0.0.1:5000/inconnu:1.0": 127.0.0.1:5000/inconnu:1.0: not found

This registry stays in HTTP on the loopback interface. Served to other machines, it must move to TLS with authentication, as in the lesson on the trusted registry.

Why is a pull-through mirror not an offline solution?

A pull-through mirror is a registry that relays an upstream registry and keeps a copy of what is asked of it. Starting it with REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io gives a mirror of Docker Hub. Online, it works: a docker pull of an image by digest succeeds, and its cache grows. But offline it does not stay up: with registry 3.1.2, it contacts the upstream at startup and stops with a panic.

Terminal window
docker exec machine-isolee docker volume create miroir
docker exec machine-isolee docker run -d --name miroir -p 127.0.0.1:5001:5000 -v miroir:/var/lib/registry -e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io registry:3.1.2@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3
sleep 15
docker exec machine-isolee docker logs miroir 2>&1 | grep '^panic'
docker exec machine-isolee docker pull 127.0.0.1:5001/library/busybox:1.37
panic: Get "https://registry-1.docker.io/v2/": dial tcp: lookup registry-1.docker.io on 10.10.114.1:53: read udp 172.17.0.4:45198->10.10.114.1:53: i/o timeout
Error response from daemon: failed to resolve reference "127.0.0.1:5001/library/busybox:1.37": failed to do request: Head "https://127.0.0.1:5001/v2/library/busybox/manifests/1.37": dial tcp 127.0.0.1:5001: connect: connection refused

The first line is the panic message of the mirror: it contacts the upstream at its startup, gets no answer (timeout on the name resolution), panics and stops. A few seconds after the launch, the container goes from Up to Exited (2).

The pull therefore fails in two ways. While the container is Up, it answers connection reset by peer; once stopped, it answers connection refused, since nothing listens on the port anymore. The second error is the one shown by the output above, read after the sleep 15. A pull-through mirror speeds up pulls and reduces calls to the Hub as long as the network works, but it does not replace an offline delivery.

Its cache, on the other hand, is a simple registry volume. The steps below can be reproduced as an exercise, on the model of the previous section (the output shown further down comes from this run):

  1. start the same mirror on the connected side, in a miroir-connecte container published with -p 127.0.0.1:5003:5000, whose miroir-cache volume is mounted on /var/lib/registry;
  2. pull the image into it by its digest, with docker pull 127.0.0.1:5003/library/hello-world@sha256:c567b231f65614688711a75f31518a064258a51d442117a3e771a3a8fdba78ef: the mirror downloads it from Docker Hub and keeps it in its volume;
  3. stop it, then archive its volume like the one in the previous section;
  4. restore the archive into the miroir volume of the isolated machine;
  5. remove on the connected side the miroir-connecte container, its miroir-cache volume and the image pulled in step 2, which the final cleanup also handles.

Served by an ordinary registry, without the REGISTRY_PROXY_REMOTEURL variable, this volume answers:

Terminal window
docker exec machine-isolee docker rm -f miroir
docker exec machine-isolee docker run -d --name miroir-nu -p 127.0.0.1:5002:5000 -v miroir:/var/lib/registry registry:3.1.2@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3
docker exec machine-isolee docker pull 127.0.0.1:5002/library/hello-world@sha256:c567b231f65614688711a75f31518a064258a51d442117a3e771a3a8fdba78ef
docker exec machine-isolee docker run --rm --pull never --network none 127.0.0.1:5002/library/hello-world@sha256:c567b231f65614688711a75f31518a064258a51d442117a3e771a3a8fdba78ef
Status: Downloaded newer image for 127.0.0.1:5002/library/hello-world@sha256:c567b231f65614688711a75f31518a064258a51d442117a3e771a3a8fdba78ef
127.0.0.1:5002/library/hello-world@sha256:c567b231f65614688711a75f31518a064258a51d442117a3e771a3a8fdba78ef
Hello from Docker!

The cache of a mirror therefore travels like any registry volume. It contains only the images already requested from this mirror: what you never pulled online is not in it. The Distribution documentation also states that the mirror periodically deletes old content from its cache: a volume copied long after the last pull may have lost images.

What to do when the offline delivery fails?

Every failure in the lesson has a message that designates it. Look for yours in the first column.

Message or symptomCauseAction
failed to resolve source metadata … not found in a build, on a machine that reaches the registryThe registry answers, but it does not have the digest of the FROMRead the index digest with docker buildx imagetools inspect --format '{{.Manifest.Digest}}'
The RUN cat /etc/alpine-release line displays a version other than the tag of the FROMThe tag is ignored when a digest is presentFix the tag so that it matches the pinned digest
docker save busybox:1.37: No such image after a pull by digestpull tag@digest does not set the tagdocker tag busybox@sha256:… busybox:1.37, then docker save
no suitable export target found … does not provide the specified platformThe platform requested with --platform is not in the local storePull it first with docker pull --platform
failed to ingest … short read … unexpected EOF at docker loadArchive truncated during the transferRedo the transfer, then sha256sum -c
Error unpacking image … content digest … not found with rc=0Tampered archive: docker load does not signal it through its codeVerify the sha256sum -c receipt before any loading
No such image: alpine@sha256:<index digest> after loading an archive made with --platformThe archive carries only the manifest: the loaded image has the manifest digest, not the index digestRedo the archive without --platform, or pin the FROM on the manifest digest
failed to do request: Head "…/manifests/sha256:<index digest>" … network is unreachable when building a FROM pinned on the index, after loading an archive made with --platformThe loaded image carries the manifest digest: the engine does not find the index locally and looks for it on the unreachable registryRedo the archive without --platform, or pin the FROM on the manifest digest
exec format error when launching a delivered imageArchive of an architecture other than the machine'sExport the right platform (pull that variant, then --platform)
network is unreachable at docker pull (for a build, see the row on the index digest)The machine has no network, which is expectedDeliver by archive or registry, and run with --pull never
No such image with --pull neverThe image has not been loadeddocker load, or docker pull from the delivered registry
panic: Get "https://registry-1.docker.io/v2/" in the logs of a registry, then connection reset by peer or connection refused at docker pullA pull-through mirror contacts the upstream at startup, panics and stops (Exited (2))Serve the cache volume with an ordinary registry
You have reached your pull rate limit (HTTP 429) at docker pull or in the verifie-socle.sh scriptDocker Hub limits the pulls of an address or an accountAuthenticate or wait, see Publish and pull an image in a controlled way
failed to resolve reference … : not found on the delivered registryDigest or repository absent from the transported registryRead _catalog and tags/list to see what it contains

A failure at the docker pull by digest step, after a delivery by registry, may come from a digest copied from Docker Hub: remember that docker push of a local image sends only the manifest of the local platform, whose digest differs from the one of the index.

How do you clean up after the experiments?

The lesson leaves on your workstation a privileged container, a registry, a volume, images and archives. Remove the isolated machine first: its regvol and miroir volumes, its registre-livraison and miroir-nu containers and the images it loaded all live in the daemon it contains. The -v option of docker rm -fv matters here: the docker:dind image declares /var/lib/docker as a volume, and Docker creates an anonymous volume for it that carries the whole inner daemon and the delivery data. Without -v, the anonymous volume of the daemon stays on your workstation.

Then remove the registry of the connected machine and its livraison-vol volume, then the images of the lesson and the transfer folders. If a docker image rm command answers that an image does not exist, it has already disappeared: the other names in the list are still processed, but the command exits with code 1. If it answers that an image is used by a container, delete that container first with docker rm -f.

Terminal window
docker rm -fv machine-isolee
docker rm -f registre-livraison
docker volume rm livraison-vol
# only if you did the mirror exercise on the connected side
docker rm -f miroir-connecte
docker volume rm miroir-cache
docker image rm 127.0.0.1:5003/library/hello-world@sha256:c567b231f65614688711a75f31518a064258a51d442117a3e771a3a8fdba78ef
docker image rm t:menteur demo-web:1.0 127.0.0.1:5001/demo-web:1.0 127.0.0.1:5001/reference/alpine:3.24.3
docker image rm busybox:1.37 registry:3.1.2 alpine:3.24.3
docker image rm docker:dind@sha256:9e79b879b177fbd72c34376a3dd3d91747da5db49e1f560f76aea59561471977
rm -rf xfer alt v1 v2 app menteur verifie-socle.sh

The alpine, busybox and registry images may serve other uses on your machine: keep them if so. Check with docker image ls that no <none> image from the lesson remains: an image pulled by digest without a tag, or the version-1 base that became orphaned when you rebuilt it under the same tag. Delete it then by its identifier. The 127.0.0.1:5001/socle-maison:2026-11 image does not appear in the list: the section on the moved tag has already deleted it.

The last rm -rf also removes the v1/, v2/, app/ and menteur/ folders and the verifie-socle.sh script, written along the sections. The -f option avoids an error for a folder that you did not create, alt/ for example if you skipped the tampering demonstration. Re-read the list before running the command: it asks for no confirmation.

Practice

The first lab asks you to keep a build on the intended base while the tag of that base changes in a local registry: the validation rebuilds the image after a source change and checks the base actually delivered. The second asks you to prepare a published image for a machine with no network, with its integrity checksum: the validation checks that the image can be retrieved offline without depending on the local Docker cache.

Key points

The index digest of a tag is read with docker buildx imagetools inspect <tag> --format '{{.Manifest.Digest}}', without pulling the image, and is written in the FROM in the form name:tag@sha256:…. When a digest is present, Docker ignores the tag without warning: a tag that does not match the digest misleads the reader, not the build.

A pinned build stays on the old content when the tag is moved, as long as the registry still serves that digest. To know that a newer base exists, compare the pinned digest with the one the registry serves for the tag, which requires keeping the tag in the FROM, and make the task fail through a non-zero exit code.

docker pull tag@digest does not set the tag: without docker tag, a docker save by name fails. docker save without options keeps the index and exports the platforms present in the local store. --platform chooses only among them and reduces the archive to a manifest: the loaded image then carries the manifest digest, and a FROM pinned on the index no longer resolves there. To keep the index digest, export without --platform. The archive is created as -rw-------.

The exit code of docker load does not prove integrity: a tampered archive loads with code 0. You write a sha256sum receipt before the transfer and verify it with sha256sum -c before loading. This receipt protects against a damaged archive; against deliberate tampering, it must travel by a channel other than the archive.

Delivering through a registry amounts to transporting the registry image and its volume, stopped during the copy, then pulling the image by digest on the isolated machine. A docker push of a local image sends only the local platform, whose digest is not the one of the index. --pull never proves that the execution does not need the network.

A pull-through mirror does not replace an offline delivery: it stops with a panic shortly after its launch when its upstream is unreachable. Its cache, served by an ordinary registry, answers, but it contains only what had been requested online. The --privileged --network none container that simulates the isolated machine is a demonstration device, not a delivery method.

Knowledge check

Check that the essentials of this lesson have stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

8 questions
8 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn