Container Structure Tests
Summary
- Container structure tests assert facts about a built image - files, permissions, exposed ports, environment, and command output - without deploying it.
- Insight: they close the gap between "the Dockerfile looks right" and "the image contains what I claimed," which linting alone cannot verify.
- Key Concepts: command tests, file existence and content tests, metadata tests, declarative YAML, CI enforcement.
- When to Use: on every image where a wrong user, missing binary, or wrong port would cause a runtime failure or a security gap.
- Limitations: they inspect a static image, not live behavior - pair them with a smoke test that actually runs the container.
Recipe
Write a YAML test spec, then run Google's container-structure-test against the built image.
docker build -t app:ci .
container-structure-test test --image app:ci --config structure-test.yamlWorking Example
A realistic spec covers the three test families: metadata, files, and command output.
schemaVersion: 2.0.0
metadataTest:
user: "10001"
workdir: "/app"
exposedPorts: ["8080"]
env:
- key: NODE_ENV
value: production
entrypoint: ["node"]
cmd: ["dist/server.js"]
fileExistenceTests:
- name: app bundle present
path: /app/dist/server.js
shouldExist: true
- name: no shell in final image
path: /bin/sh
shouldExist: false
fileContentTests:
- name: CA certificates bundled
path: /etc/ssl/certs/ca-certificates.crt
shouldExist: true
commandTests:
- name: node is the expected major version
command: node
args: ["--version"]
expectedOutput: ["v22\\..*"]
- name: process user is non-root
command: id
args: ["-u"]
expectedOutput: ["10001"]Run it in CI right after the build and before push.
- name: Structure tests
run: |
docker build -t app:ci .
container-structure-test test --image app:ci \
--config structure-test.yamlThe tool loads the image, runs command tests inside an ephemeral container, inspects the filesystem for the file tests, and reads config metadata for the metadata tests.
Any failed assertion returns a non-zero exit code, which fails the pipeline.
Deep Dive
Metadata tests
metadataTest reads the image config directly, so it needs no running process.
It is the fastest way to assert user, exposedPorts, workdir, entrypoint, cmd, and env match your contract.
Asserting user: "10001" here catches a base image that silently reset the user back to root.
File tests
fileExistenceTests confirm required artifacts exist and forbidden ones do not - for example, that no shell ships in a distroless final stage.
fileContentTests go further and match file contents against a regex, which is useful for config files or CA bundles.
Both run against the image's layered filesystem without starting a process.
Command tests
commandTests start a throwaway container, run a command, and match expectedOutput, excludedOutput, or expectedError against regexes.
Use them to verify a binary is on PATH, reports the right version, or that the effective UID is non-root.
Because they execute, keep them cheap - a version print, not a full server boot.
Schema and versions
Pin schemaVersion: 2.0.0 so a tool upgrade does not silently change parsing.
Keep the spec in the repo next to the Dockerfile so changes are reviewed together.
Gotchas
Distroless and scratch images have no shell, so commandTests that rely on /bin/sh will fail - call binaries directly instead.
Command tests need a runnable entrypoint context; if your image sets a restrictive entrypoint, override it in the test command rather than fighting it.
exposedPorts asserts the EXPOSE metadata, not that anything listens - a smoke test is still required for real connectivity.
Regexes in expectedOutput are anchored loosely, so tighten them (for example ^10001$) when an exact match matters.
Running structure tests before the build, or against a stale tag, tests the wrong image - always build first and reference the fresh tag or digest.
Alternatives
Goss with dgoss runs assertions inside a live container, which overlaps with structure tests but leans toward runtime health checks.
A plain docker run plus shell assertions works for one-off checks but lacks the declarative, reviewable spec.
InSpec can profile a running container against compliance controls, which is heavier and aimed at audit rather than build-time gating.
Pick container-structure-test for fast, static, build-time contracts; pick Goss or a smoke test when you need to observe a running process.
FAQs
Does it deploy to Kubernetes? No - it tests the image locally in CI, before it ever reaches a node running containerd.
Can it run without Docker? It supports the docker driver by default and can also test images via other drivers, but the Docker driver is the common CI path.
How is this different from linting? Linting reads the Dockerfile source; structure tests read the actual built image, catching drift the linter cannot see.
Should command tests boot my server? No - keep them to quick, deterministic commands; use a smoke test for real startup.
Where do I store the spec? In the repo beside the Dockerfile so it is versioned and reviewed with image changes.
Related
- Why Images Need Their Own Quality Gates
- Linting Basics
- dive & Image Efficiency
- Smoke Tests After Build
- Linting Best Practices
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).