Should I test that?

Should I test Dockerfiles?

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

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 the decision changes
WhenDecisionWhy
The build copies the repository without a .dockerignore or passes a token as a build argument, and people outside the team can pull the imageTest mandatory: fail CI when the built image holds an .env file or a token in its layer historyBlast 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 editionTest: run the smoke test on every published architecture before the release tagReversibility 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 boxesTest: render one export with a non-Latin name inside the built image in CIDetectability 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 probeDo not add a smoke test; keep the readiness probeDetectability 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 yearDo not add a smoke test; check the staging deploy after each Dockerfile changeChange frequency falls to rarely, so a check by hand costs less than a test to keep
The Dockerfile builds only a local development imageDo not test the development image; fix it when a developer's setup breaksBlast 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.

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 run docker exec with 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 scratch image. 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.