Hadolint in CI: Linting Dockerfiles for Correctness and Security

Hadolint in CI: Linting Dockerfiles for Correctness and Security

Hadolint is a Dockerfile linter that catches common mistakes, security antipatterns, and best-practice violations before your images are built. A misconfigured Dockerfile can produce bloated images, introduce vulnerabilities, or fail silently in production. Hadolint in CI prevents these issues from reaching your registries.

Why Dockerfile Linting Matters

Dockerfiles are often written quickly and rarely revisited. Without linting, teams accumulate issues:

  • Pinning apt-get packages without versions leads to non-reproducible builds
  • Using latest tags creates silent breaks when upstream images update
  • Running as root in containers unnecessarily expands the attack surface
  • Not combining RUN instructions creates unnecessary image layers
  • Missing HEALTHCHECK instructions prevents orchestrators from detecting unhealthy containers

Hadolint checks for all of these automatically.

Installing Hadolint

# macOS
brew install hadolint

<span class="hljs-comment"># Linux (binary)
curl -sSfL https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64 \
  -o /usr/local/bin/hadolint && <span class="hljs-built_in">chmod +x /usr/local/bin/hadolint

<span class="hljs-comment"># Docker (no install needed)
docker run --<span class="hljs-built_in">rm -i hadolint/hadolint < Dockerfile

Basic Usage

# Lint a Dockerfile
hadolint Dockerfile

<span class="hljs-comment"># Lint with specific rules ignored
hadolint --ignore DL3008 --ignore DL3009 Dockerfile

<span class="hljs-comment"># Output as JSON for tooling
hadolint --format json Dockerfile

<span class="hljs-comment"># Fail only on errors (not warnings)
hadolint --failure-threshold error Dockerfile

Common Hadolint Rules

DL3008 — Pin versions in apt-get install:

# Bad
RUN apt-get install -y python3

# Good  
RUN apt-get install -y python3=3.11.1-1

DL3009 — Delete apt-get lists after install:

# Bad
RUN apt-get update && apt-get install -y curl

# Good
RUN apt-get update && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

DL3006 — Always tag the version of the image:

# Bad
FROM ubuntu

# Good
FROM ubuntu:22.04
# Better (pinned digest)
FROM ubuntu:22.04@sha256:abc123...

DL3025 — Use shell form for CMD:

# Hadolint prefers exec form for CMD
CMD ["python", "app.py"]  # exec form — correct
CMD python app.py          # shell form — DL3025 warning

SC2086 — Double-quote shell variables:

# Bad (word splitting risk)
RUN echo $MY_VAR

# Good
RUN echo "$MY_VAR"

Note: Hadolint uses ShellCheck for shell script validation within RUN instructions — both tools complement each other.

Configuration File

Create .hadolint.yaml in your repository root:

# .hadolint.yaml
failure-threshold: warning

# Rules to ignore globally
ignore:
  - DL3008  # Pinning apt versions too strict for some teams
  - DL3059  # Multiple consecutive RUN instructions acceptable

# Trust specific registries for FROM instructions
trustedRegistries:
  - gcr.io
  - registry.mycompany.com

# Override severity for specific rules
override:
  warning:
    - DL3006
  error:
    - SC2086

# Per-file overrides (for multi-stage builds)
rules:
  - DL3008

Or inline in Dockerfile comments:

# hadolint ignore=DL3008
RUN apt-get install -y python3

GitHub Actions Integration

# .github/workflows/lint.yml
name: Dockerfile Lint
on:
  push:
    paths:
      - '**/Dockerfile'
      - '**/Dockerfile.*'
  pull_request:
    paths:
      - '**/Dockerfile'

jobs:
  hadolint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: hadolint/hadolint-action@v3.1.0
        with:
          dockerfile: Dockerfile
          failure-threshold: warning
          format: sarif
          output-file: hadolint-results.sarif
      
      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: hadolint-results.sarif

SARIF output integrates with GitHub's Security tab, showing Dockerfile issues alongside code scanning results.

For multiple Dockerfiles:

- name: Find all Dockerfiles
  id: find-dockerfiles
  run: |
    files=$(find . -name "Dockerfile*" -not -path "*/node_modules/*" | tr '\n' ' ')
    echo "files=$files" >> $GITHUB_OUTPUT

- name: Lint Dockerfiles
  run: |
    for file in ${{ steps.find-dockerfiles.outputs.files }}; do
      echo "Linting $file"
      hadolint "$file"
    done

Security-Focused Rules

Hadolint catches several security issues:

DL3002 — Last USER should not be root:

# Bad — container runs as root
FROM node:20
COPY . /app
CMD ["node", "server.js"]

# Good
FROM node:20
COPY . /app
USER node
CMD ["node", "server.js"]

DL4006 — Set SHELL option -o pipefail:

# Bad — pipe failure silently ignored
RUN wget -qO- https://example.com/install.sh | bash

# Good
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN wget -qO- https://example.com/install.sh | bash

DL3020 — Use COPY instead of ADD for files:

# Bad — ADD can auto-extract archives, unexpected behavior
ADD config.tar.gz /etc/app/

# Good — explicit
COPY config.tar.gz /tmp/
RUN tar -xzf /tmp/config.tar.gz -C /etc/app/ && rm /tmp/config.tar.gz

Combining with Other CI Checks

Dockerfile linting fits naturally in a broader container security pipeline:

jobs:
  dockerfile-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      # 1. Lint the Dockerfile
      - uses: hadolint/hadolint-action@v3.1.0
        with:
          dockerfile: Dockerfile
      
      # 2. Build the image
      - name: Build
        run: docker build -t myapp:test .
      
      # 3. Scan for vulnerabilities in the built image
      - uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:test
          format: sarif
          output: trivy-results.sarif
      
      # 4. Upload security findings
      - uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

Testing Hadolint Rules Locally

Before adding a new Dockerfile to CI, test it locally:

# Get a summary of all issues
hadolint --format <span class="hljs-built_in">tty Dockerfile

<span class="hljs-comment"># Check a specific rule
hadolint --ignore DL3008 --no-fail Dockerfile

<span class="hljs-comment"># Test with production-like settings
hadolint --config .hadolint.yaml Dockerfile

For teams using multiple Dockerfiles across microservices, create a shared linting script:

#!/bin/bash
<span class="hljs-comment"># scripts/lint-dockerfiles.sh
<span class="hljs-built_in">set -e

ERRORS=0
<span class="hljs-keyword">while IFS= <span class="hljs-built_in">read -r -d <span class="hljs-string">'' dockerfile; <span class="hljs-keyword">do
    <span class="hljs-built_in">echo <span class="hljs-string">"Checking: $dockerfile"
    <span class="hljs-keyword">if ! hadolint --config .hadolint.yaml <span class="hljs-string">"$dockerfile"; <span class="hljs-keyword">then
        ERRORS=$((ERRORS + <span class="hljs-number">1))
    <span class="hljs-keyword">fi
<span class="hljs-keyword">done < <(find . -name <span class="hljs-string">"Dockerfile*" -not -path <span class="hljs-string">"*/node_modules/*" -print0)

<span class="hljs-keyword">if [ <span class="hljs-string">"$ERRORS" -gt 0 ]; <span class="hljs-keyword">then
    <span class="hljs-built_in">echo <span class="hljs-string">"$ERRORS Dockerfile(s) failed linting"
    <span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"All Dockerfiles passed"

Connecting Dockerfile Quality to Runtime Testing

A linted Dockerfile produces a better image. But a correctly structured image still needs to be tested in production conditions — that the service starts correctly, responds to health checks, and handles the traffic patterns your users generate.

HelpMeTest monitors your containerized services after deployment, running behavioral checks that verify your containers actually work for users — complementing your Dockerfile linting with runtime validation.

Summary

  • Hadolint catches Dockerfile best-practice violations, security antipatterns, and shell script errors
  • Configure with .hadolint.yaml for team-wide rules; use inline # hadolint ignore= for exceptions
  • --failure-threshold error allows warnings but blocks on errors — useful for gradual adoption
  • SARIF output integrates with GitHub Security tab for centralized security findings
  • Combine with Trivy for image vulnerability scanning in the same CI pipeline
  • DL3002 (last USER not root) and DL4006 (pipefail) are the most security-critical rules

Read more