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

Promote the same artifact between two environments

By updated

Improve this page

Logo Docker

The image running in production must be exactly the one you tested in staging. If each environment rebuilds it, you test one image and deploy another, even with an identical Dockerfile. This lesson shows you how to build once, transfer the archive to the second host, deploy two environments that differ only by their configuration, then prove that the running image is the one you prepared. The outputs come from Docker Engine 29.9.2 and Compose v5.6.1 on Debian 13, with the containerd image store, on two hosts. The files and projects of the lesson call staging recette, the French word, and the outputs keep that name.

Three words come up everywhere. An artifact is the result of a build that you move without redoing it: here, a Docker image. Its Id is the sha256 digest that identifies it, and two images with the same Id are identical byte for byte. The provenance is a small file that records where the artifact comes from (the commit, the Id, the checksum of the archive). To promote an artifact is to move it from one environment to the next without rebuilding it.

What you will learn

  • Observe that rebuilding the same Dockerfile gives another image Id, and draw the rule: build once, then promote
  • Transfer a docker save archive to a second host, verify it with sha256sum -c, load it with docker load and find the same Id
  • Write a common Compose base and one override per environment, then read the effective configuration with docker compose config
  • Spot the hidden rebuild of docker compose up when the image is missing and the service keeps a build section, and turn it into an error with --no-build --pull never
  • Separate two environments with a project (-p), and recreate one without touching the other
  • Read the Id of the running image, compare it to the provenance file, then run a business check before promoting

Prerequisites

  • Development and deployment: explicit variants: merging a base and an override with two -f options, and reading docker compose config. The lesson uses them without explaining them again.
  • Automate the build, tests and publication of an image: the provenance file that links the commit, the Id of the image and the checksum of the archive, with docker save and docker load. Here you compare both with what runs.
  • Two Linux hosts with Docker Engine 29.9.2 and Compose v5.6.1, SSH access to each and curl. The build host can also run staging. If your two hosts cannot reach each other, go through your workstation as a relay, as we did. The two virtual machines of the lab offered at the end of the lesson can serve as hosts, provided they carry these versions.

One part of the lesson (the hidden rebuild) downloads the python base image from Docker Hub: the target host therefore needs network access to reproduce it.

Why build only once?

A rebuild does not necessarily give back the same image, even with the same sources. The Dockerfile of the sample application, a small "catalogue" server, writes the build time into the image: a realistic case of content that changes at every build. On the build host, we built the same thing four times, with the same pinned base, and read the Id of each result.

BuildId obtained (first 12 characters)
First build, cat-catalogue:1.05aa54177fc5c
Rebuild with cache75018aed6f00
Rebuild with --no-cachec791e168e025
Second rebuild with --no-cacheca993d2e2fc5

Look at the second row: the build with cache already gave another Id, although every layer came from the cache. We observed this with the containerd store of Docker 29.9.2. The detail matters: when building twice in a row with --provenance=false, which removes the provenance attestation added to the image by default, both images got the same Id (78affc679754), cache included. With the default attestation, the rebuild with cache gave another one. So it is this attestation that makes the Id vary when the cache is used. It describes how the image was built and has its uses: the rule of the lesson is not to disable it, but to build only once. Remember that a build does not guarantee the same result.

An environment that rebuilds therefore deploys an artifact nobody has tested. The rule is to build once, then move that artifact as it is: that is what promoting means.

The following diagram shows the path the lesson follows, from the single build to the deployment.

Promotion flow. The build host builds the image once (Id 5aa54177), writes a provenance file and a docker save archive with its sha256 checksum. The archive is transferred to the target host, where sha256sum -c then docker load give back the same Id. The deployment with up -d --no-build --pull never keeps that Id. Dotted, from the target host: if the image is missing there and the service keeps a build section, up -d rebuilds without warning and produces another Id.transferbuild section keptBuild hostdocker build, onceId 5aa54177provenance.txtcommit, Id, checksumdocker save archive + sha256sumTarget hostsha256sum -c then docker loadId 5aa54177up -d --no-build--pull neversame IdTarget hostimage missingup -d with a build sectionother Id: hidden rebuild
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Follow the thick line: it leads from the single build to the deployment that keeps the Id, through the archive, the transfer and the load. The dotted line starts from the target host when the image is missing there: this is the danger covered in the section "Why does Compose rebuild silently?".

How do I prepare the sample application and the working folder?

Everything happens in a ~/c44 folder, created on both hosts, which the final cleanup removes in one go. On the build host, create it with its app subfolder; on the target host, create only ~/c44, since the app folder arrives by scp a little further down. The "catalogue" application serves two routes: /catalogue returns its environment and three titles, /sante answers ok. At each start, it appends a line to /donnees/demarrages.log: this is the trace that will prove that the data of a volume survives a recreation.

Terminal window
mkdir -p ~/c44/app && cd ~/c44

Write app/app.py with this content.

import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
ENV = os.environ.get("CAT_ENV", "inconnu")
LIVRES = ["Le Petit Prince", "Germinal", "Les Misérables"]
# Each start leaves a line in the volume: the proof that the data survives.
with open("/donnees/demarrages.log", "a") as f:
f.write(ENV + "\n")
class H(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/sante":
body, code = b"ok\n", 200
elif self.path == "/catalogue":
body, code = json.dumps({"env": ENV, "livres": LIVRES}).encode() + b"\n", 200
else:
body, code = b"introuvable\n", 404
self.send_response(code)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
HTTPServer(("0.0.0.0", 8000), H).serve_forever()

Then app/Dockerfile. The base carries its digest, and the RUN date step produces the content that changes at every build, mentioned above.

FROM python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2
RUN date -u +%s%N > /etc/build-stamp && mkdir /donnees
COPY app.py /app/app.py
CMD ["python", "/app/app.py"]

Next, copy the app folder itself to the target host, with scp -r app, and not its content with scp -r app/*: this second gesture flattens the build context, and the build fails for lack of a Dockerfile. The target host does not need this folder to deploy, but the demonstration of the hidden rebuild uses it further down.

How do I transfer the artifact without rebuilding it?

On the build host, build the image only once, read its Id and export it to an archive together with its checksum.

Terminal window
docker build -t cat-catalogue:1.0 app
docker image inspect cat-catalogue:1.0 --format "Id={{.Id}}"
docker save -o cat-catalogue-1.0.tar cat-catalogue:1.0
sha256sum cat-catalogue-1.0.tar | tee cat-catalogue-1.0.tar.sha256
Id=sha256:5aa54177fc5c4f47aa3cca02ca7d27d650a9461c694567a55986cfa876c36542
bf419e265457a50a497d5e7b208f609aad74a35e23220d2ae352ad423ec6faa0 cat-catalogue-1.0.tar

Keep the Id: it is the identity of the artifact. With the containerd store, docker image ls --digests shows the same sha256 in the DIGEST and IMAGE ID columns (5aa54177fc5c). In other words, the Id is the digest of the OCI index, the list that describes the image for each of its platforms. The archive weighs 17,870,848 bytes.

Then write the provenance file, as in the CI lesson: it links the commit, the Id and the checksum of the archive. The commit a1b2c3d in our example is fictitious.

Terminal window
ID=$(docker image inspect cat-catalogue:1.0 --format '{{.Id}}')
SHA=$(cut -d' ' -f1 cat-catalogue-1.0.tar.sha256)
printf 'commit=a1b2c3d\nimage=cat-catalogue:1.0\nid=%s\narchive=cat-catalogue-1.0.tar\narchive_sha256=%s\n' "$ID" "$SHA" > provenance.txt
cat provenance.txt
commit=a1b2c3d
image=cat-catalogue:1.0
id=sha256:5aa54177fc5c4f47aa3cca02ca7d27d650a9461c694567a55986cfa876c36542
archive=cat-catalogue-1.0.tar
archive_sha256=bf419e265457a50a497d5e7b208f609aad74a35e23220d2ae352ad423ec6faa0

Transfer the three files (the archive, its checksum and provenance.txt) to ~/c44 on the target host with scp. In our case, the two hosts could not reach each other: the files went through the workstation, from the build host to the workstation then from the workstation to the target host, and both scp returned 0. On the target host, verify the archive before loading it.

Terminal window
sha256sum -c cat-catalogue-1.0.tar.sha256
cat-catalogue-1.0.tar: OK

The check must be able to fail, otherwise it proves nothing. We altered one byte of a copy of the archive (by writing X at position 100) and ran sha256sum -c again: it answered alteree.tar: FAILED then sha256sum: WARNING: 1 computed checksum did NOT match, with exit code 1. Never load an archive for which this check does not return 0: transfer it again from the build host, without rebuilding the image on the target, which would produce another artifact. When the check answers OK, load the archive.

Terminal window
docker load -i cat-catalogue-1.0.tar
docker image inspect cat-catalogue:1.0 --format '{{.Id}}'
Loaded image: cat-catalogue:1.0
sha256:5aa54177fc5c4f47aa3cca02ca7d27d650a9461c694567a55986cfa876c36542

Look at the Id: it is the one from the build host and the one in the provenance file, character for character. Compare them with a test rather than by eye, but also test that the two values are not empty.

Terminal window
ATTENDU=$(grep '^id=' provenance.txt | cut -d= -f2)
REEL=$(docker image inspect cat-catalogue:1.0 --format '{{.Id}}')
[ -n "$ATTENDU" ] && [ "$REEL" = "$ATTENDU" ] && echo "identique à la provenance"

The -n test is not a luxury. During our measurements, a diff between two empty Id values, coming from a build that had failed, printed "identical". Two empty values are equal: a comparison that does not check it received something can succeed on nothing.

A trusted registry can replace this archive when one exists: the digest of the manifest then plays the role of the Id. This variant, with its pitfalls, is covered in BuildKit cache in CI and image promotion between registries, and the lesson does not replay it.

How do I change only the configuration between two environments?

The image does not change from one environment to the next, its settings do. The compose.yaml base describes what is common, with an image: and no build section. It carries only a placeholder value for CAT_ENV. Put the files of this section in ~/c44; the target host needs only compose.yaml and compose.prod.yaml.

services:
catalogue:
image: cat-catalogue:1.0
environment:
CAT_ENV: inconnu
volumes:
- donnees:/donnees
volumes:
donnees: {}

Each environment adds an override: a second Compose file, passed after the base, that carries only what differs. Here, that is the variable, the memory limit and the publication on the loopback interface (127.0.0.1, reachable from the host only). Here is compose.recette.yaml, then compose.prod.yaml. Neither redefines image:.

services:
catalogue:
environment:
CAT_ENV: recette
mem_limit: 96m
ports:
- "127.0.0.1:8601:8000"
services:
catalogue:
environment:
CAT_ENV: production
mem_limit: 192m
ports:
- "127.0.0.1:8602:8000"

Before launching anything, read the merged configuration. The -f options apply in order: the base first, the override next. The -p option names the Compose project, that is, the set of containers, network and volume that one command creates. This name also prefixes those of the network and the volume.

Terminal window
docker compose -p cat-recette -f compose.yaml -f compose.recette.yaml config
name: cat-recette
services:
catalogue:
environment:
CAT_ENV: recette
image: cat-catalogue:1.0
mem_limit: "100663296"
networks:
default: null
volumes:
- type: volume
source: donnees
target: /donnees
volume: {}
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 8000
published: "8601"
protocol: tcp
networks:
default:
name: cat-recette_default
volumes:
donnees:
name: cat-recette_donnees

In this output, look at three things. image: cat-catalogue:1.0 comes from the base, with no build section. mem_limit is converted to bytes (96 MiB gives "100663296"). The project gives its names to the network (cat-recette_default) and to the volume (cat-recette_donnees).

The production configuration, read the same way, gives CAT_ENV: production, "201326592" bytes, port "8602" and names in cat-prod_.

The check that matters for a promotion is the absence of a build section in the merged configuration, for each environment.

Terminal window
for e in recette prod; do
echo -n "$e : lignes build = "
docker compose -p cat-$e -f compose.yaml -f compose.$e.yaml config | grep -c build
done
recette : lignes build = 0
prod : lignes build = 0

Mind the exit code: grep -c returns 1 when it counts 0. Here, 0 is the right result, but a script that chains this grep with &&, or that runs under set -e, stops on a success. Read the count, not the exit code.

The effective configuration says what Compose is going to ask for. What runs is read in the container, once the deployment is done: the section "How do I separate two environments on one host?" repeats this check on the running containers.

One last check: the base must not have moved. Record its checksum before any operation with sha256sum compose.yaml | tee base.sha256, then verify it again at the end with sha256sum -c base.sha256. In our session, it stayed identical.

Why does Compose rebuild silently?

When a service keeps a build section and its image is missing from the host, docker compose up -d builds it without being asked. To see it, we put a third override on the target host, compose.avec-build.yaml, which gives the service its build: ./app back, then removed the image from that host.

services:
catalogue:
build: ./app
Terminal window
docker rmi cat-catalogue:1.0
docker compose -p cat-prod -f compose.yaml -f compose.prod.yaml -f compose.avec-build.yaml up -d

The command took 5.8 seconds and returned 0. It printed Image cat-catalogue:1.0 Built, then created the network, the volume and the container. The host did not have the python base image either: to build, it had to fetch it, which requires network access on the host. Now read the Id of the image, then query the application.

Terminal window
docker image inspect cat-catalogue:1.0 --format '{{.Id}}'
curl -fsS http://127.0.0.1:8602/catalogue
sha256:f6cbb17368c4164f41964af1c629650a426e3381eccbae114902a80ab4544f14
{"env": "production", "livres": ["Le Petit Prince", "Germinal", "Les Mis\u00e9rables"]}

The tag is still cat-catalogue:1.0 and the application answers correctly, but the Id is no longer 5aa54177, the one in the provenance. Nothing raised an alert: that is the danger. A check that merely runs a curl validates this production, although it is not running the promoted artifact.

The deployment must therefore fail instead of rebuilding or downloading. Two options do it: --no-build forbids building, --pull never forbids downloading. On the same host, after stopping the project (down -v) and removing the image again (docker rmi), relaunch the deployment with the build section kept:

Terminal window
docker compose -p cat-prod -f compose.yaml -f compose.prod.yaml -f compose.avec-build.yaml \
up -d --no-build --pull never
echo "rc=$?"
Network cat-prod_default Creating
Network cat-prod_default Creating
Volume cat-prod_donnees Creating
Volume cat-prod_donnees Creating
Volume cat-prod_donnees Created
Volume cat-prod_donnees Created
Network cat-prod_default Created
Network cat-prod_default Created
Container cat-prod-catalogue-1 Creating
service:catalogue:1 Error response from daemon: No such image: cat-catalogue:1.0
Error response from daemon: No such image: cat-catalogue:1.0
rc=1

Outside a terminal, Compose repeats some progress lines: this is the output as it writes it. Read the message carefully: it mentions neither build nor download. It is the daemon that answers No such image when Compose asks it to create the container. No container exists after the failure: docker compose ... ps -a is empty, and docker ps -a --filter label=com.docker.compose.project=cat-prod prints only its header. But the cat-prod_default network and the cat-prod_donnees volume were created before, and they are still there: a failure leaves leftovers to clean up.

On the promotion configuration, without a build section, we got the same message and the same code 1 with up -d --no-build --pull never and with up -d --pull never alone. Without a build section, --pull never is therefore enough to prevent the download; --no-build protects the case where a build section has slipped into the configuration. Keep both options in your deployment commands.

When the image is loaded and the configuration no longer carries a build section, the same deployment succeeds.

Terminal window
docker load -i cat-catalogue-1.0.tar
docker compose -p cat-prod -f compose.yaml -f compose.prod.yaml up -d --no-build --pull never
echo "rc=$?"
docker inspect cat-prod-catalogue-1 --format '{{.Image}}'
Loaded image: cat-catalogue:1.0
Container cat-prod-catalogue-1 Creating
Container cat-prod-catalogue-1 Created
Container cat-prod-catalogue-1 Starting
Container cat-prod-catalogue-1 Started
rc=0
sha256:5aa54177fc5c4f47aa3cca02ca7d27d650a9461c694567a55986cfa876c36542

For a container, {{.Image}} gives the Id of the image it comes from. The one of the production container is indeed the one in the provenance: it runs on the promoted artifact.

One last point is measured on the build host, once production has been launched on it as in the next section, with the image present and the build section kept. It also needs compose.avec-build.yaml, which we had put only on the target host: copy it. A plain up -d does not rebuild (Container ... Running, Id of the container and of the image unchanged). An up -d --build rebuilds, retags cat-catalogue:1.0 to another Id and recreates the container.

Terminal window
docker compose -p cat-prod -f compose.yaml -f compose.prod.yaml -f compose.avec-build.yaml up -d --build 2>&1 | grep -E 'Built|Recreate|Started'
docker inspect cat-prod-catalogue-1 --format 'img={{.Image}}'
Image cat-catalogue:1.0 Built
Container cat-prod-catalogue-1 Recreate
Container cat-prod-catalogue-1 Recreated
Container cat-prod-catalogue-1 Started
img=sha256:55e681386acd8022d34a91e4acedec8d888c5e46f7fca8db31f5eee472ebc9d2

The production container no longer runs on the artifact of the provenance, and nothing failed. A forgotten --build option in a deployment script is enough to change what runs. If you replay this test, restore the artifact afterwards: docker load puts the cat-catalogue:1.0 tag back on the Id of the archive, and the promotion deployment recreates the container on it.

Terminal window
docker load -i cat-catalogue-1.0.tar
docker compose -p cat-prod -f compose.yaml -f compose.prod.yaml up -d --no-build --pull never
docker inspect cat-prod-catalogue-1 --format 'prod img={{.Image}}'
Loaded image: cat-catalogue:1.0
Container cat-prod-catalogue-1 Recreate
Container cat-prod-catalogue-1 Recreated
Container cat-prod-catalogue-1 Starting
Container cat-prod-catalogue-1 Started
prod img=sha256:5aa54177fc5c4f47aa3cca02ca7d27d650a9461c694567a55986cfa876c36542

How do I separate two environments on one host?

One project per environment separates containers, network and volume. On the build host, we launched staging and production side by side with the same image.

Terminal window
docker compose -p cat-recette -f compose.yaml -f compose.recette.yaml up -d --no-build --pull never
docker compose -p cat-prod -f compose.yaml -f compose.prod.yaml up -d --no-build --pull never
docker volume ls --filter name=cat-
docker network ls --filter name=cat-
DRIVER VOLUME NAME
local cat-prod_donnees
local cat-recette_donnees
NETWORK ID NAME DRIVER SCOPE
9f132ee95d3e cat-prod_default bridge local
4c48f51fc32d cat-recette_default bridge local

Each environment has its own volume, its own network and its own port: the two projects share neither name nor data, although they come from the same compose.yaml.

Before comparing them, check what really runs. The effective configuration says what Compose asked for; docker inspect says what the daemon applied. Read the memory limit (HostConfig.Memory, in bytes), the port publication (HostConfig.PortBindings) and the CAT_ENV variable among the variables of the container (Config.Env, a list to which the image itself adds several entries, hence the grep).

Terminal window
for e in recette prod; do
echo "== $e"
docker inspect cat-$e-catalogue-1 --format "Memory={{.HostConfig.Memory}} PortBindings={{json .HostConfig.PortBindings}}"
docker inspect cat-$e-catalogue-1 --format "{{range .Config.Env}}{{println .}}{{end}}" | grep "^CAT_ENV="
done
== recette
Memory=100663296 PortBindings={"8000/tcp":[{"HostIp":"127.0.0.1","HostPort":"8601"}]}
CAT_ENV=recette
== prod
Memory=201326592 PortBindings={"8000/tcp":[{"HostIp":"127.0.0.1","HostPort":"8602"}]}
CAT_ENV=production

The values are those of the overrides: 96 MiB gives 100663296 bytes, 192 MiB gives 201326592, and each port is published only on 127.0.0.1.

To verify that the data is separate, write a note.txt file in the volume of each project and read the identifier and start time of each container. The -T option of exec disables the pseudo-terminal, which is needed here because the command receives its input through a pipe.

Terminal window
echo donnee-recette | docker compose -p cat-recette -f compose.yaml -f compose.recette.yaml exec -T catalogue tee /donnees/note.txt
echo donnee-prod | docker compose -p cat-prod -f compose.yaml -f compose.prod.yaml exec -T catalogue tee /donnees/note.txt
docker inspect cat-recette-catalogue-1 --format 'recette avant: {{.Id}} {{.State.StartedAt}}'
docker inspect cat-prod-catalogue-1 --format 'prod avant: {{.Id}} {{.State.StartedAt}}'
donnee-recette
donnee-prod
recette avant: 1e1dde4b4075ed0a3864cc58b476f76fed14eead0b0d4ce4e981d6712d3b09db 2026-11-04T13:04:21.459337784Z
prod avant: b2c32451dfdde4a1d5595ebcd961e8497f96a44fe8b5447ada9a4f5eb03baf86 2026-11-04T13:04:21.725411729Z

Then recreate staging alone, and read both containers again.

Terminal window
docker compose -p cat-recette -f compose.yaml -f compose.recette.yaml up -d --force-recreate --no-build --pull never
docker inspect cat-recette-catalogue-1 --format 'recette apres: {{.Id}} {{.State.StartedAt}}'
docker inspect cat-prod-catalogue-1 --format 'prod apres: {{.Id}} {{.State.StartedAt}}'
Container cat-recette-catalogue-1 Recreate
Container cat-recette-catalogue-1 Recreated
Container cat-recette-catalogue-1 Starting
Container cat-recette-catalogue-1 Started
recette apres: 0194798526aa112fd5f78ac925b83cc05a72c81d92f6139296cf04e46fe04f3a 2026-11-04T13:04:42.251887786Z
prod apres: b2c32451dfdde4a1d5595ebcd961e8497f96a44fe8b5447ada9a4f5eb03baf86 2026-11-04T13:04:21.725411729Z

The staging container has a new identifier (01947985… instead of 1e1dde4b…), while the production one (b2c32451…) and its StartedAt are unchanged. What remains is to read the data.

Terminal window
docker compose -p cat-recette -f compose.yaml -f compose.recette.yaml exec -T catalogue cat /donnees/note.txt /donnees/demarrages.log
docker compose -p cat-prod -f compose.yaml -f compose.prod.yaml exec -T catalogue cat /donnees/note.txt /donnees/demarrages.log
donnee-recette
recette
recette
donnee-prod
production

The staging note.txt is intact, its demarrages.log holds two recette lines (two starts), and production keeps a single one, production.

Remember what this shows: recreating a project keeps its volume and touches neither the container nor the data of the other. That is not the case of down -v, which deletes the volume of the project along with its data; the lesson uses it only for the cleanup.

How do I trace a promotion and validate it?

To prove what runs, read the Id of the image of each container with docker inspect and compare it to the one in the provenance, with the same non-empty test as above.

Terminal window
ID=$(grep '^id=' provenance.txt | cut -d= -f2)
for c in cat-recette-catalogue-1 cat-prod-catalogue-1; do
[ -n "$ID" ] && [ "$(docker inspect $c --format '{{.Image}}')" = "$ID" ] \
&& echo "$c identique à la provenance" || echo "$c DIFFÉRENT"
done
cat-recette-catalogue-1 identique à la provenance
cat-prod-catalogue-1 identique à la provenance

If the ID variable is empty (provenance file missing or badly read), the -n test fails and the loop prints DIFFÉRENT for each container, instead of wrongly concluding that they are identical.

An identical Id proves that this is the right artifact, not that it works in this environment. The next promotion therefore depends on a business check, that is, a test of what the application must do (here, serve its catalogue) rather than of its mere startup. Here is verifier.sh, which queries /catalogue, checks the announced environment, counts the titles of the catalogue (the strings that start with a capital letter) and fails if the catalogue is empty. It takes the port and the expected environment. The --noproxy '*' option of curl stops it from going through a proxy defined in the environment of the shell: with http_proxy=http://127.0.0.1:9 (an address where nothing listens), a curl without this option failed with code 7, and with it, it answered.

#!/bin/sh
set -eu
rep=$(curl -fsS --noproxy '*' --max-time 3 "http://127.0.0.1:$1/catalogue")
echo "$rep" | grep -q "\"env\": \"$2\"" || { echo "ECHEC: environnement attendu $2, reçu $rep"; exit 1; }
n=$(echo "$rep" | grep -o '"[A-Z][^"]*"' | grep -vc '"env"' || true)
[ "$n" -gt 0 ] || { echo "ECHEC: catalogue vide"; exit 1; }
echo "OK: $2 répond, catalogue non vide ($n entrées lues)"
Terminal window
chmod +x verifier.sh
./verifier.sh 8601 recette; echo "rc=$?"
./verifier.sh 8601 production; echo "rc=$?"
OK: recette répond, catalogue non vide (3 entrées lues)
rc=0
ECHEC: environnement attendu production, reçu {"env": "recette", "livres": ["Le Petit Prince", "Germinal", "Les Mis\u00e9rables"]}
rc=1

The script must be able to fail, and the second command proves it: asking for production on the staging port returns code 1. It must also refuse an empty catalogue, because an application that answers without serving anything is not ready to be promoted. To see it, serve an empty catalogue on another port with the Python HTTP server, which stops by itself after 6 seconds thanks to timeout.

Terminal window
mkdir -p vide
echo '{"env": "recette", "livres": []}' > vide/catalogue
timeout 6 python3 -m http.server 8603 --bind 127.0.0.1 --directory vide >/dev/null 2>&1 &
sleep 1
./verifier.sh 8603 recette; echo "rc=$?"
wait
ECHEC: catalogue vide
rc=1

It is this code 1 that would block the next promotion. When the check succeeds, record the promotion in a log: date, environment, Id and result. The && operator writes the line only if verifier.sh succeeded.

Terminal window
res=$(./verifier.sh 8601 recette) && echo "$(date -u +%FT%TZ) recette id=$(docker inspect cat-recette-catalogue-1 --format '{{.Image}}') verification=$res" | tee -a promotions.log
2026-11-04T13:04:45Z recette id=sha256:5aa54177fc5c4f47aa3cca02ca7d27d650a9461c694567a55986cfa876c36542 verification=OK: recette répond, catalogue non vide (3 entrées lues)

We checked it the other way round: with ./verifier.sh 8601 production instead, the assignment returned code 1 and promotions.log did not receive a second line.

This log answers, long afterwards, the question "what ran, when, and had we checked it?".

Who decides to promote?

The decision to promote stays human. This lesson shows the mechanism that guarantees the promoted artifact is the right one; it does not say who is allowed to promote it. Protected environments and mandatory reviewers belong to the CI provider, and are learned in the courses devoted to it. For GitHub Actions, the page Promoting an artifact: build once, deploy twice shows this approval around a promotion by digest.

How do I clean up what the lesson left behind?

Each host keeps projects, images, build cache and the ~/c44 folder. Stop the projects with -v to also delete their volumes (the lesson keeps none of them), remove the images, then delete the working folder.

Terminal window
cd ~/c44
docker compose -p cat-recette -f compose.yaml -f compose.recette.yaml down -v
docker compose -p cat-prod -f compose.yaml -f compose.prod.yaml down -v
docker rmi cat-catalogue:1.0
docker builder prune -f
cd ~ && find ~/c44 -delete

Depending on the host, some projects do not exist: staging runs only on the build host in our session. If you built other tags, remove them the same way with docker rmi. The python base image does not appear in docker image ls -a after the builds, and a docker rmi on it answers No such image: only the build cache keeps a trace of it. Careful: docker builder prune -f empties the build cache of the whole machine, not only that of the lesson; skip it if you build other images on that host. In our case, docker image ls -a, docker volume ls, docker ps -a and docker system df showed nothing left on either host at the end.

Practice

The lab repeats the gesture of the lesson on two virtual machines: one application, a single artifact, two Compose deployments that differ only by their configuration. The setup builds the image on the first machine, then loads the archive of that image on the second one, without rebuilding it on production. You write one override per machine, in the working folder of the lab, and merge them with the base through two -f options.

The validation compares the identity of the prepared image to the two running images, refuses a build key in the merged configuration, verifies that the base stayed unchanged, reads the variable, the memory limit and the publication in each container, then queries both services. It then recreates each project, with up -d --force-recreate --no-build --pull never, to observe that its data survives and that the other one is not touched. The promotion log and the business check seen in the section "How do I trace a promotion and validate it?" remain gestures to practice by hand: the validation does not check them. It does not check either that you use --no-build --pull never: it uses them itself when it recreates an environment.

Key points

  • A rebuild of the same Dockerfile can give another Id, even with the cache (observed with the containerd store, the default provenance attestation being the cause): build once, then promote that artifact.
  • The transfer by archive follows four gestures: docker save, transfer, sha256sum -c (which must return 0), docker load. The Id after loading is the one from the build host and from the provenance. Compare them with a test that also checks they are not empty: two empty values are equal.
  • The configuration lives outside the image: a common base without build, one override per environment, merged in that order. docker compose config must show zero build lines.
  • When a service keeps a build section and the image is missing, docker compose up -d rebuilds without saying so, under the same tag but with another Id, and the application still answers: nobody sees the substitution. --no-build --pull never turns this rebuild into an error (No such image, code 1, a message that mentions neither build nor download). And up -d --build retags the image and replaces the artifact.
  • One project per environment (-p) gives each its own volume and network. Recreating one keeps its data and leaves the other intact.
  • Trace the promotion: the Id read by docker inspect is compared to the provenance, and a successful business check in the target environment precedes the next promotion.
  • The decision to promote stays human: the approval belongs to the GitHub Actions and GitLab CI courses.
  • Clean up the projects (down -v), the images, the cache and the ~/c44 folder on both hosts.

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

6 questions
6 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