Verdict
Yes
Write one smoke test in CI that starts the built image, waits for its health endpoint and loads every native library the application uses, and do not assert the Dockerfile's contents.
Why
- Blast radiususers
- Change frequencyregularly
- Detectabilitysame-day
- Reversibilitytrivial
- Test costmoderate
Test a Dockerfile minimally: one smoke test in CI that starts the image it builds. Blast radius is users, because production runs that image. Change frequency is regularly: a base image bump or a dependency that needs a system package changes the image about once a month. Detectability is same-day, since an image that builds can still fail on one route, and error reports arrive within hours. Reversibility is trivial, because a redeploy of the previous tag leaves nothing behind, and Test cost is moderate: the test starts a container and waits for it.
| When | Decision | Why |
|---|---|---|
| The build copies the repository without a .dockerignore or passes a token as a build argument, and people outside the team can pull the image | Test mandatory: fail CI when the built image holds an .env file or a token in its layer history | Blast radius rises to safety-or-legal, Detectability to never and Reversibility to impossible, because a leaked secret cannot be recalled |
| Customers pull the image and run it themselves, such as a self-hosted edition | Test: run the smoke test on every published architecture before the release tag | Reversibility moves to costly, because self-hosted customers install releases by hand, so each installation stays broken until its owner installs the fix |
| A headless browser in the image renders PDF exports, and a missing font package prints non-Latin names as empty boxes | Test: render one export with a non-Latin name inside the built image in CI | Detectability moves to eventually and Reversibility to with-effort, because the export succeeds and files already sent must be regenerated |
| The image adds no packages or native modules to its base, and the deploy keeps the old containers until new ones pass a readiness probe | Do not add a smoke test; keep the readiness probe | Detectability moves to immediately, because an image that fails to start stops the deploy in front of the developer who changed it |
| The service is in maintenance, and its Dockerfile and base image change a few times a year | Do not add a smoke test; check the staging deploy after each Dockerfile change | Change frequency falls to rarely, so a check by hand costs less than a test to keep |
| The Dockerfile builds only a local development image | Do not test the development image; fix it when a developer's setup breaks | Blast radius falls to internal, because no customer runs the development image |
What breaks if you don't test
CI runs the unit tests on the runner and then builds the image. Neither step runs the application inside the image, so a missing system package or a native module compiled for another C library passes both. The container starts and passes its health check, which loads none of those parts, and the first customer on the affected path gets an error.
What you lose if you over-test
A structure test that asserts every file, environment variable, port and label repeats the Dockerfile line by line. Every intended edit then fails the test and needs a second edit in the test config. A structure test also passes when a listed library exists and the application still cannot load it, because it never starts the process that reads the file.
How to test
After docker build, start the image with production settings, poll its health endpoint, and load every lazily loaded native library inside the container. Lint the Dockerfile with hadolint in the same job. Exclude node_modules, .git and .env in a .dockerignore, and pass tokens as build secrets, because build arguments persist in the final image.
When the answer changes
- Customers run the image on their own servers, so a broken release stays there until they upgrade.
- The image gains a part the health check cannot reach, such as a headless browser for PDF exports.
- The deploy waits for a readiness probe, and the image adds nothing to its base.
Real incident + Code example
The avatar uploads that broke on Alpine
On a Node.js service I worked on, CI ran npm ci and the tests on an Ubuntu runner, then built a node:20-alpine image with COPY . . and no .dockerignore. The copy carried the runner's glibc build of sharp, an image library, and Alpine uses musl. The server started and passed its health check, because it loaded sharp only on the upload route. Every avatar upload returned a 500 until a user reported it three hours after the deploy. We added a .dockerignore, moved npm ci into the Dockerfile, and added this CI step:
#!/usr/bin/env bash
# Runs after docker build. Fails if the image cannot serve or load sharp.
set -euo pipefail
IMAGE="app:${GITHUB_SHA}"
docker run -d --name smoke -p 3000:3000 -e NODE_ENV=production "$IMAGE"
trap 'docker logs smoke; docker rm -f smoke' EXIT
for _ in $(seq 1 30); do
curl -fsS http://localhost:3000/healthz > /dev/null && break
sleep 1
done
curl -fsS http://localhost:3000/healthz
# Native modules the app loads lazily, loaded inside the image
docker exec smoke node -e "require('sharp')"
The last line fails on the image that broke uploads, and a new base image leaves the step unchanged.
Related questions
FAQ
- Should you test Docker images?
Test a Docker image with one CI smoke test that starts the container, calls its health endpoint and loads each native library. A successful build shows only that every Dockerfile instruction ran.
- How do I smoke test a Docker image in CI?
Start the image with
docker run, poll its health endpoint until it answers, and rundocker execwith a command that loads each native library. Print the container logs when the step fails.- Should I use container-structure-test?
Use container-structure-test only for a file whose absence has already broken production, such as the CA certificate bundle in a
scratchimage. A structure test for every file repeats the Dockerfile.- Is linting a Dockerfile with hadolint enough?
Hadolint alone is not enough, because it reads the Dockerfile and never starts the image. It flags unpinned packages and shell mistakes, and only a smoke test sees a library that fails to load.