Skip to content

Hadolint — Dockerfile Linting

Hadolint is a Dockerfile linter that enforces best practices, catches common mistakes, and ensures Dockerfiles follow security-hardening guidelines. It runs as an early pipeline stage (Stage 2B) immediately after the source checkout and before the Docker build.


Positioning in the Pipeline

Pipeline Stages
──────────────────────────────────────────────────────────────
Stage  1: Gitleaks         — Secret scanning
Stage  2: Build Prep       — Restore, lint
  Stage 2A: dotnet restore / npm install
  Stage 2B: Hadolint       — Dockerfile lint  ◄──
Stage  3: SonarCloud       — SAST
...
Stage  8: Docker Build     — Multi-stage Dockerfile
──────────────────────────────────────────────────────────────

Linting before building catches Dockerfile issues without spending 5–10 minutes on the build, giving developers faster feedback.


What Hadolint Checks

Hadolint checks fall into two categories: Hadolint rules (DL*) and ShellCheck rules (SC*) for RUN instructions.

Key Rules Enforced

RuleSeverityDescription
DL3002WarningUSER root — do not switch to root user
DL3006WarningMissing tag in FROM — always pin image versions
DL3007Warninglatest tag in FROM — use specific version
DL3008Warningapt-get packages without fixed version
DL3009Infoapt-get lists should be cleaned up after install
DL3025WarningUse COPY instead of ADD for local files
DL3033Warningyum packages without fixed version
DL3045InfoCOPY to a relative path without WORKDIR set
DL3047Infowget without --progress=dot:giga flag
DL4001Warningwget and curl both present in the same image
DL4006Warningset -o pipefail missing in multi-pipe shell commands
SC2086StyleDouble-quote shell variables to prevent word splitting

Pipeline Integration

Stage 2B — Hadolint task:

yaml
- stage: Lint
  displayName: 'Stage 2B - Dockerfile Lint (Hadolint)'
  dependsOn: []
  jobs:
    - job: Hadolint
      pool:
        vmImage: 'ubuntu-latest'
      steps:
        - checkout: self

        - task: Bash@3
          displayName: 'Run Hadolint on all Dockerfiles'
          inputs:
            script: |
              # Find all Dockerfiles in the repo
              DOCKERFILES=$(find . \
                -name 'Dockerfile' \
                -o -name 'Dockerfile.*' \
                | grep -v node_modules \
                | grep -v '.git')

              echo "Found Dockerfiles:"
              echo "$DOCKERFILES"

              FAILED=0

              for dockerfile in $DOCKERFILES; do
                echo "──── Linting: $dockerfile ────"
                docker run --rm \
                  -v "$(Build.SourcesDirectory):/work" \
                  hadolint/hadolint:latest-debian \
                    hadolint \
                      --config /work/.hadolint.yaml \
                      --format sarif \
                      "/work/${dockerfile#./}" \
                  | tee "$(Build.ArtifactStagingDirectory)/hadolint-$(basename $(dirname $dockerfile)).sarif"

                if [ "${PIPESTATUS[0]}" -ne 0 ]; then
                  FAILED=1
                fi
              done

              exit $FAILED

        - task: PublishBuildArtifacts@1
          condition: always()
          inputs:
            pathToPublish: '$(Build.ArtifactStagingDirectory)'
            artifactName: 'security-hadolint'

Configuration File (.hadolint.yaml)

The Hadolint configuration file is placed at the repository root and customizes which rules are enforced and which are ignored:

yaml
# .hadolint.yaml — repository root
failure-threshold: warning   # Fail on warning or higher (not info/style)

# ── Rules to ignore (with justification) ────────────────────────
ignore:
  # DL3008: apt-get packages without version — acceptable in build stage only
  # We pin the base image version; apt package versions are transitive
  - DL3008

  # DL3009: Delete apt-get lists — handled by multi-stage build
  # The runtime stage never includes apt caches
  - DL3009

  # SC2016: Expressions in single quotes — used intentionally in heredocs
  - SC2016

# ── Trusted base image registries ────────────────────────────────
trustedRegistries:
  - mcr.microsoft.com    # Microsoft Container Registry (dotnet images)
  - quay.io              # Red Hat Quay (Keycloak)

# ── Per-rule overrides ────────────────────────────────────────────
override:
  error:
    - DL3002   # USER root must always be an error (not just warning)
  warning:
    - DL3007   # latest tag in FROM
    - DL4006   # Missing pipefail

Severity Levels

Levelfailure-thresholdPipeline Effect
errorerrorBlocks regardless
warningwarningBlocks (our current threshold)
infoinfoBlocks only at info threshold
stylestyleBlocks only at style threshold
ignoredNever shown

Setting failure-threshold: warning means info and style findings are reported but do not block the pipeline. All warning and error findings block the build.


Per-File Inline Ignores

For specific Dockerfile lines where a rule genuinely does not apply, use inline ignore comments:

dockerfile
# Use --no-install-recommends to reduce image size (DL3015 — ignore for this project)
# hadolint ignore=DL3015
RUN apt-get install -y --no-install-recommends curl

# We intentionally use ADD here for URL fetch (DL3020)
# hadolint ignore=DL3020
ADD https://example.com/config.tar.gz /config/

Use Inline Ignores Sparingly

Inline ignores bypass the rule for that specific line. Prefer fixing the Dockerfile to comply with the rule. Add a comment explaining why the rule is being ignored. Do not use broad hadolint ignore=all annotations.


Common Violations and Fixes

DL3002 — Running as Root

dockerfile
# BAD
FROM mcr.microsoft.com/dotnet/aspnet:8.0
# Runs as root by default

# GOOD
FROM mcr.microsoft.com/dotnet/aspnet:8.0
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
USER appuser

DL3006 — Untagged FROM

dockerfile
# BAD
FROM mcr.microsoft.com/dotnet/aspnet

# GOOD
FROM mcr.microsoft.com/dotnet/aspnet:8.0

DL3007latest Tag

dockerfile
# BAD
FROM mcr.microsoft.com/dotnet/aspnet:latest

# GOOD — pin to specific minor version
FROM mcr.microsoft.com/dotnet/aspnet:8.0.5

DL4006 — Missing pipefail

dockerfile
# BAD
RUN wget https://example.com/file.tar.gz | tar xz

# GOOD
RUN set -o pipefail && \
    wget https://example.com/file.tar.gz | tar xz

DL3025ADD Instead of COPY

dockerfile
# BAD — ADD has implicit tar extraction and URL support (unexpected behavior)
ADD ./configs /app/configs

# GOOD — COPY is explicit and predictable
COPY ./configs /app/configs

DL3045 — Relative COPY Without WORKDIR

dockerfile
# BAD — where does the file go?
COPY config.json .

# GOOD
WORKDIR /app
COPY config.json .

Running Hadolint Locally

bash
# Install (macOS)
brew install hadolint

# Lint a single Dockerfile
hadolint Platforms/Src/Gateway/Dockerfile

# Lint all Dockerfiles in the repo
find . -name Dockerfile | xargs hadolint --config .hadolint.yaml

# JSON output (for IDE integration)
hadolint --format json Dockerfile

# Using Docker (no local install required)
docker run --rm -i hadolint/hadolint < Dockerfile

IDE Integration

IDEPlugin
VS Codeexiasr.hadolint extension
JetBrains RiderBuilt-in Dockerfile inspection
Vimale with hadolint linter

The VS Code extension reads .hadolint.yaml automatically and shows inline warnings.


Reporting

Hadolint SARIF output is published as a pipeline artifact (security-hadolint) and can be imported into Azure DevOps Advanced Security:

bash
# Upload SARIF to Azure DevOps Advanced Security
az devops security sarif upload \
  --project erp \
  --sarif-file hadolint-Gateway.sarif \
  --build-id $(Build.BuildId)

Internal Documentation — Microtec Platform Team