Testing Dockerfiles: hadolint, container-structure-test, and Layer Validation

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: Dockerfile

What 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.com

The 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 curl

Use 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-test

Writing 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.yaml

What to test structurally

Good candidates for structure tests:

  1. Runtime is present and the right versionnode --version, python --version, java -version
  2. Non-root user is set — running as root is a security risk; verify your USER instruction worked
  3. Sensitive files are absent.env, SSH keys, credentials files should never be in a production image
  4. Environment variables — verify NODE_ENV=production, PORT=3000, etc. are set correctly
  5. Working directoryWORKDIR should be set to a known path
  6. Exposed ports — document and verify what the container expects to expose
  7. 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:latest

It 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:latest

In 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.20

Common 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.gz

apt-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 . /app

Testing 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.yaml

This 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:test

Practical 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:test

One 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.

Read more

Start now free