Testing Dockerfiles: hadolint, container-structure-test, and Layer Validation
Dockerfiles are code. Treat them like code. Use hadolint to catch anti-patterns at write time, container-structure-test to verify what your image actually contains, and layer inspection to keep builds lean and fast. This guide walks through a practical testing pipeline for Dockerfiles from lint to CI enforcement.
Most teams treat Dockerfiles as an afterthought — something that gets committed once and never really tested. Then one day the image balloons to 4GB, someone installs curl with --no-check-certificate, or a layer invalidation bug means every docker build downloads half the internet. These problems are preventable. The tools exist. Most teams just haven't wired them up.
This guide covers the full Dockerfile testing stack: static analysis with hadolint, structural verification with Google's container-structure-test, layer inspection, multi-stage build testing, and how to run all of it in CI without slowing your pipeline down.
Why Dockerfiles Need Tests
A Dockerfile is infrastructure code. It defines the runtime environment for your application. Bugs in it can mean:
- Security vulnerabilities from running as root or including unnecessary packages
- Unpredictable builds because of mutable tags like
FROM node:latest - Slow CI because layers aren't ordered to maximize cache hits
- Fat images that cost money to pull and push across the network
- Broken applications because the final image doesn't contain what you think it does
You wouldn't ship application code without tests. The same discipline should apply to your Dockerfiles.
Static Analysis with hadolint
hadolint is a Dockerfile linter written in Haskell. It parses your Dockerfile into an AST and checks it against a ruleset derived from Docker best practices.
Installation
# macOS
brew install hadolint
# Docker (no install required)
docker run --rm -i hadolint/hadolint < Dockerfile
# GitHub Actions
- uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: DockerfileWhat hadolint catches
Run it against a naive Dockerfile and you'll see output like:
Dockerfile:3 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
Dockerfile:5 DL3009 info: Delete the apt-get lists after installing something
Dockerfile:8 DL4006 warning: Set the SHELL option -o pipefail before RUN with a pipe in it
Dockerfile:12 SC2086 info: Double quote to prevent globbing and word splitting.Each rule has an ID in the DL (Dockerfile) or SC (ShellCheck) namespace. The SC rules come from shellcheck, which hadolint embeds — so it also validates the shell commands inside your RUN instructions.
Key rule categories:
| Prefix | Covers |
|---|---|
| DL3000 | FROM instructions (pinning, base image) |
| DL3020 | ADD vs COPY |
| DL4000 | MAINTAINER deprecation |
| SC2xxx | Shell script anti-patterns in RUN |
Configuring hadolint
Create .hadolint.yaml at the root of your repo:
failure-threshold: warning
ignore:
- DL3008 # allow unpinned apt packages in dev images
trustedRegistries:
- docker.io
- ghcr.io
- your-internal-registry.example.comThe failure-threshold key controls what exit code causes a pipeline failure. Setting it to warning means any warning-level finding fails the build. Setting it to error is more permissive — only errors block the pipeline.
Inline ignore comments
Sometimes a rule is legitimately wrong for your situation. Suppress it inline:
# hadolint ignore=DL3008
RUN apt-get install -y curlUse this sparingly. Every suppression is a decision you're making explicitly — that's fine, as long as it's intentional.
Structural Testing with container-structure-test
hadolint analyzes the Dockerfile source. container-structure-test analyzes the built image. These are complementary — you need both.
container-structure-test is a Google project that lets you write YAML specs asserting what an image should contain after it's built.
Installation
# macOS
brew install container-structure-test
# Binary
curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-darwin-amd64
chmod +x container-structure-test-darwin-amd64
mv container-structure-test-darwin-amd64 /usr/local/bin/container-structure-testWriting structure tests
Create container-structure-test.yaml:
schemaVersion: "2.0.0"
commandTests:
- name: "node is installed"
command: "node"
args: ["--version"]
expectedOutput: ["v20\\."]
- name: "app user exists"
command: "id"
args: ["appuser"]
expectedOutput: ["uid="]
- name: "application starts without error"
command: "node"
args: ["-e", "require('./src/index')"]
exitCode: 0
fileExistenceTests:
- name: "entrypoint exists"
path: "/app/entrypoint.sh"
shouldExist: true
permissions: "-rwxr-xr-x"
- name: "secrets not baked in"
path: "/root/.aws/credentials"
shouldExist: false
- name: "node_modules present"
path: "/app/node_modules"
shouldExist: true
fileContentTests:
- name: "correct node version in .nvmrc"
path: "/app/.nvmrc"
expectedContents: ["20\\."]
metadataTests:
- env:
- key: "NODE_ENV"
value: "production"
- exposedPorts: ["3000"]
- user: "appuser"
- workdir: "/app"Run it:
container-structure-test test \
--image myapp:latest \
--config container-structure-test.yamlWhat to test structurally
Good candidates for structure tests:
- Runtime is present and the right version —
node --version,python --version,java -version - Non-root user is set — running as root is a security risk; verify your
USERinstruction worked - Sensitive files are absent —
.env, SSH keys, credentials files should never be in a production image - Environment variables — verify
NODE_ENV=production,PORT=3000, etc. are set correctly - Working directory —
WORKDIRshould be set to a known path - Exposed ports — document and verify what the container expects to expose
- Entrypoint/CMD — verify the entrypoint is executable and correct
Layer Inspection and Validation
Every RUN, COPY, and ADD instruction creates a new layer. Understanding your layers helps you optimize build cache, keep image size down, and verify that multi-stage builds correctly exclude build artifacts.
Inspecting layers with dive
dive is a terminal UI for exploring image layers:
brew install dive
dive myapp:latestIt shows you each layer, its size, and exactly what files were added or changed. You can also run it in CI mode:
dive --ci myapp:latestIn CI mode, dive exits non-zero if the image efficiency score drops below a threshold, or if there are wasted bytes (files added in one layer and deleted in a later layer).
Configure thresholds in .dive-ci.yaml:
rules:
lowestEfficiency: 0.95
highestWastedBytes: "20MB"
highestUserWastedPercent: 0.20Common layer anti-patterns
Deleting files in a separate RUN layer:
# BAD — the file still exists in the earlier layer
RUN wget https://example.com/big-archive.tar.gz
RUN tar -xf big-archive.tar.gz
RUN rm big-archive.tar.gz
# GOOD — single layer, file never persists
RUN wget https://example.com/big-archive.tar.gz && \
tar -xf big-archive.tar.gz && \
rm big-archive.tar.gzapt-get lists not cleaned up:
# BAD
RUN apt-get update && apt-get install -y curl
# GOOD
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*COPY ordering that breaks cache:
# BAD — any source change invalidates package install layer
COPY . /app
RUN npm install
# GOOD — package.json changes rarely; source changes often
COPY package*.json /app/
RUN npm ci
COPY . /appTesting Multi-Stage Builds
Multi-stage builds are the right way to separate build-time dependencies from runtime images. But they introduce a new failure mode: the wrong artifacts might get copied from the wrong stage.
Verify artifacts were excluded
The most common bug in multi-stage builds is accidentally including build tools in the final image. Test for it:
# container-structure-test.yaml — final stage checks
fileExistenceTests:
- name: "no build tools in final image"
path: "/usr/local/bin/gcc"
shouldExist: false
- name: "no npm in final image"
path: "/usr/local/bin/npm"
shouldExist: false
- name: "compiled binary present"
path: "/app/server"
shouldExist: true
permissions: "-rwxr-xr-x"Test intermediate stages explicitly
Docker lets you build and tag specific stages:
docker build --target builder -t myapp:builder .
docker build --target final -t myapp:final .Run structure tests against each stage:
container-structure-test test \
--image myapp:builder \
--config tests/builder-stage.yaml
container-structure-test test \
--image myapp:final \
--config tests/final-stage.yamlThis catches issues like missing build dependencies in the builder stage or incorrect COPY paths between stages.
Putting It Together in CI
Here's a complete GitHub Actions workflow that runs the full Dockerfile testing pipeline:
name: Dockerfile Tests
on:
push:
paths:
- 'Dockerfile*'
- '.hadolint.yaml'
- 'container-structure-test*.yaml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
failure-threshold: warning
build-and-test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
tags: myapp:test
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Install container-structure-test
run: |
curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64
chmod +x container-structure-test-linux-amd64
sudo mv container-structure-test-linux-amd64 /usr/local/bin/container-structure-test
- name: Run structure tests
run: |
container-structure-test test \
--image myapp:test \
--config container-structure-test.yaml
- name: Layer efficiency check
run: |
curl -LO https://github.com/wagoodman/dive/releases/download/v0.12.0/dive_0.12.0_linux_amd64.tar.gz
tar -xzf dive_0.12.0_linux_amd64.tar.gz
sudo mv dive /usr/local/bin/
CI=true dive --ci myapp:testPractical Tips
Start with hadolint on existing Dockerfiles. Don't try to fix everything at once. Set failure-threshold: error initially to only block on the most serious issues, then tighten it as you fix findings.
Write structure tests as you write the Dockerfile. Every RUN, COPY, and USER instruction should have a corresponding test. If you add a new dependency, add a test that verifies it's installed and the right version.
Keep the base image pinned to a digest, not a tag. Tags are mutable. node:20-alpine today might be a different image next month. For production images:
FROM node:20-alpine@sha256:abc123...Test your .dockerignore. A .dockerignore that doesn't exclude node_modules or .git will make your builds slow and potentially leak sensitive files. Test it:
docker build --no-cache -t myapp:test . 2>&1 | grep "Sending build context"Watch the "Sending build context" size. If it's hundreds of megabytes, your .dockerignore needs work.
Automate the full pipeline locally with a Makefile:
.PHONY: test-dockerfile
test-dockerfile:
hadolint Dockerfile
docker build -t myapp:test .
container-structure-test test --image myapp:test --config container-structure-test.yaml
dive --ci myapp:testOne command, full pipeline, fast feedback. Run it before every push.
Summary
Dockerfile testing is not glamorous, but it catches a class of production bugs that application tests never will. The toolchain is mature:
- hadolint for static analysis — runs in seconds, no build required
- container-structure-test for structural verification — tests what the image actually contains
- dive for layer efficiency — catches bloated images and cache-busting anti-patterns
- Stage-specific builds for multi-stage validation — verify each stage independently
Wire these into CI and you'll stop shipping images that are too large, run as root, or contain the wrong binaries. That's a meaningful improvement in both security and reliability, and it costs almost nothing to set up.