← Back to DevBytes

Dockerfile Lint: Complete Configuration Guide

What Is Dockerfile Linting?

Dockerfile linting is the automated process of analyzing your Dockerfile against a predefined set of rules and best practices to catch issues before they become problems in production. A linter inspects your Dockerfile's structure, instructions, and patterns — flagging everything from security vulnerabilities and inefficient layer caching to deprecated syntax and image size bloat.

Think of it as a static code analysis tool specifically designed for Docker's build language. Unlike general-purpose shell or YAML linters, a Dockerfile linter understands the nuances of the Docker build context, layer composition, and the interplay between instructions like RUN, COPY, ADD, and FROM.

Core Tools in the Ecosystem

The most widely adopted Dockerfile linter is hadolint (Haskell Dockerfile Linter), which ships with a comprehensive rule set based on Docker's official best practices. Other notable options include:

Why Dockerfile Linting Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Skipping linting on Dockerfiles is like shipping unreviewed code straight to production. Here's what you risk:

A linter catches these issues in seconds, right in your editor or CI pipeline, long before an image reaches a registry — saving time, disk space, and potential breach vectors.

Getting Started with hadolint

Installation Methods

Option 1: Native binary download

# Linux (x86_64)
curl -L "https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64" \
  -o /usr/local/bin/hadolint && chmod +x /usr/local/bin/hadolint

# macOS (Apple Silicon)
curl -L "https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Darwin-arm64" \
  -o /usr/local/bin/hadolint && chmod +x /usr/local/bin/hadolint

# Verify installation
hadolint --version

Option 2: Docker-based execution — useful in CI environments where you want ephemeral tooling:

# Pull and run hadolint as a container
docker run --rm -i hadolint/hadolint < Dockerfile

# Or mount a directory with multiple Dockerfiles
docker run --rm -v "$PWD:/workspace" hadolint/hadolint \
  hadolint /workspace/Dockerfile /workspace/Dockerfile.prod

Option 3: Package managers

# Homebrew (macOS/Linux)
brew install hadolint

# Alpine Linux
apk add hadolint

# Arch Linux (AUR)
yay -S hadolint-bin

Your First Lint Run

Create a deliberately problematic Dockerfile to see hadolint in action:

# Save this as test.Dockerfile
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y python3
ADD https://example.com/some-script.sh /usr/local/bin/
CMD ["echo", "hello"]

Now run the linter:

hadolint test.Dockerfile

You'll see output similar to:

test.Dockerfile:1 DL3007 warning: Using latest tag for base image is dangerous
test.Dockerfile:1 DL3002 warning: Last user should be something other than root
test.Dockerfile:2 DL3008 warning: Pin versions in apt-get install
test.Dockerfile:2 DL3009 warning: Delete the apt-get lists after installing
test.Dockerfile:3 DL3018 warning: Consider using --no-install-recommends
test.Dockerfile:3 DL3059 info: Multiple consecutive RUN instructions
test.Dockerfile:4 DL3019 warning: Use COPY instead of ADD for remote URLs
test.Dockerfile:5 DL3025 warning: Use JSON notation for CMD and ENTRYPOINT arguments

Each violation includes the rule code (like DL3007), severity level, and a human-readable explanation — giving you an immediate, actionable checklist.

Configuring hadolint with a Configuration File

hadolint supports a YAML-based configuration file that lets you ignore specific rules, set failure thresholds, and define trusted registries. The default filename is .hadolint.yaml (placed in the project root or specified with --config).

Complete Configuration Reference

Here is a fully annotated configuration file covering every major option:

# .hadolint.yaml — Complete hadolint configuration

# ---------------------
# Rule Ignoring & Severity Overrides
# ---------------------
# You can ignore rules globally, change their severity,
# or whitelist them for specific paths.

ignored:
  # Globally disabled rules (use sparingly and document why)
  - DL3008          # "Pin versions in apt-get install" — ignored because we use a snapshot repo
  - DL3010          # "Use --no-cache-dir with pip" — handled via pip.conf system-wide

failure-threshold:
  # Minimum severity that causes a non-zero exit code.
  # Options: error | warning | info | style | none
  # Default is "info" — all levels cause failure.
  warning           # Only errors and warnings fail the lint

override:
  # Per-rule severity overrides. Elevate a style hint to an error,
  # or downgrade a noisy warning to info for specific directories.
  severity:
    - DL3003: error          # Always fail on use of sudo
    - DL3042: style          # Demote pip cache check to style level
  # Per-path rule customization (requires hadolint >= 2.8.0)
  path:
    - pattern: "tests/**"
      ignored:
        - DL3006            # Don't care about USER in test images
    - pattern: "legacy/**"
      severity:
        DL3007: info        # Downgrade latest-tag warnings in legacy builds

# ---------------------
# Trusted Registries
# ---------------------
# When DL3026 (untrusted image registry) fires, hadolint checks
# against this list. Images from these registries are allowed
# without explicit pinning.

trusted-registries:
  - docker.io
  - registry.mycompany.com:5000
  - quay.io
  - ghcr.io
  - public.ecr.aws

# ---------------------
# Label Validation
# ---------------------
# Require specific labels on every image (DL3047). Define a schema
# of required keys and optional allowed values.

label-schema:
  maintainer:
    required: true
    # No value constraint — any string is accepted
  org.opencontainers.image.version:
    required: true
    description: "Semantic version of the image"
  com.example.git-sha:
    required: false
    description: "Optional git commit SHA"

strict-labels:
  # When true, DL3047 fires if ANY label not in the schema is present
  # When false, extra labels are silently tolerated
  false

# ---------------------
# Allowed Registries (DL3026 alternative)
# ---------------------
# Inverse of trusted-registries: specify exactly which registries
# are permitted for base images. If an image comes from a registry
# NOT in this list, the rule fires.

allowed-registries:
  - docker.io/library
  - registry.mycompany.com:5000

# ---------------------
# Custom Allowed Packages
# ---------------------
# DL3034 and DL3036 check for pip/apt package pinning.
# You can whitelist specific package names that are exempt.

allowed-packages:
  apt:
    - ca-certificates     # Always safe to update
    - tzdata             # Timezone data, frequently refreshed
  pip:
    - setuptools
    - wheel
    - pip                # Bootstrap tools that are safe unpinned

# ---------------------
# Shell Style
# ---------------------
# Specify the shell dialect for DL4000-DL4006 checks
# (shellcheck integration inside RUN instructions).

shell: bash               # Options: bash | sh | dash | mksh | ksh

# ---------------------
# Misc Options
# ---------------------
# Disable colored output for CI log files
no-color: false
# Fail on first error instead of collecting all violations
no-fail-on-first-error: false

Loading the Configuration

Place .hadolint.yaml in your project root — hadolint auto-discovers it. Alternatively, specify it explicitly:

hadolint --config ./ci/hadolint-strict.yaml Dockerfile

When running inside a Docker container, mount the config file:

docker run --rm -i -v "$PWD/.hadolint.yaml:/app/.hadolint.yaml" \
  -v "$PWD/Dockerfile:/app/Dockerfile" \
  hadolint/hadolint hadolint --config /app/.hadolint.yaml /app/Dockerfile

Integrating Linting into Your Workflow

Editor Integration (Real-time Feedback)

Most modern editors support hadolint via language server protocol plugins:

VS Code: Install the hadolint extension by Dmitry Alexandrov. It runs linting on every save and displays violations inline with squiggly underlines and severity-colored badges.

# .vscode/settings.json
{
  "hadolint.configFile": ".hadolint.yaml",
  "hadolint.additionalArgs": [],
  "hadolint.client.hadolintBinary": "hadolint"
}

Vim/Neovim: Use ALE (Asynchronous Lint Engine) or nvim-lint:

" ~/.vimrc or init.lua equivalent
let g:ale_dockerfile_hadolint_executable = 'hadolint'
let g:ale_dockerfile_hadolint_config_file = '.hadolint.yaml'
let g:ale_linters = { 'dockerfile': ['hadolint'] }

Pre-commit Hook

Catch issues before they ever reach a commit:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/hadolint/hadolint
    rev: v2.12.0
    hooks:
      - id: hadolint
        args:
          - --config
          - .hadolint.yaml
          - --failure-threshold
          - warning

Install and run:

pre-commit install
pre-commit run hadolint --all-files

CI/CD Pipeline Examples

GitHub Actions:

# .github/workflows/lint.yml
name: Dockerfile Lint
on: [push, pull_request]

jobs:
  hadolint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Lint all Dockerfiles
        uses: hadolint/hadolint-action@v3.1.0
        with:
          dockerfile: |
            Dockerfile
            docker/Dockerfile.dev
            docker/Dockerfile.prod
          config: .hadolint.yaml
          failure-threshold: warning
          output-file: hadolint-report.json
          output-format: json
      - name: Upload lint report
        uses: actions/upload-artifact@v4
        with:
          name: hadolint-report
          path: hadolint-report.json

GitLab CI:

# .gitlab-ci.yml
dockerfile-lint:
  image: hadolint/hadolint:latest
  stage: lint
  script:
    - hadolint --config .hadolint.yaml --failure-threshold warning Dockerfile
    - hadolint --config .hadolint.yaml --failure-threshold warning docker/Dockerfile.prod
  rules:
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
    - if: $CI_COMMIT_BRANCH == 'main'

Jenkins Pipeline:

// Jenkinsfile
pipeline {
  agent any
  stages {
    stage('Lint Dockerfiles') {
      steps {
        sh '''
          docker run --rm -i \
            -v "$WORKSPACE/.hadolint.yaml:/config/.hadolint.yaml" \
            -v "$WORKSPACE:/workspace" \
            hadolint/hadolint:latest \
            sh -c "find /workspace -name 'Dockerfile*' -not -path '*/node_modules/*' \
              -exec hadolint --config /config/.hadolint.yaml {} +"
        '''
      }
    }
  }
}

Programmatic Usage (JSON Output)

For integration with dashboards, policy engines, or custom tooling, hadolint can emit structured JSON:

hadolint --format json Dockerfile

Example output:

[
  {
    "lineNumber": 1,
    "rule": "DL3007",
    "severity": "warning",
    "message": "Using latest tag for base image is dangerous",
    "code": "FROM ubuntu:latest"
  },
  {
    "lineNumber": 3,
    "rule": "DL3018",
    "severity": "info",
    "message": "Consider using --no-install-recommends",
    "code": "RUN apt-get install -y python3"
  }
]

You can pipe this into jq for custom filtering:

# Fail only if there are errors (not warnings or info)
hadolint --format json Dockerfile | \
  jq -e 'map(select(.severity == "error")) | length == 0' > /dev/null

# Extract just the rule codes for a quick summary
hadolint --format json Dockerfile | jq -r '.[].rule' | sort | uniq -c | sort -rn

Deep Dive: Understanding Key Rules

hadolint ships with over 60 built-in rules. Here are the ones that cause the most real-world pain when ignored:

DL3007 — Latest Tag

Severity: warning
Trigger: FROM node:latest
Fix: Pin to a digest or at minimum a semantic version:

# Bad
FROM node:latest

# Better — semantic version pin
FROM node:20.11.1-alpine

# Best — digest pin for complete reproducibility
FROM node:20.11.1-alpine@sha256:a1b2c3d4e5f6...

DL3008 + DL3009 — Apt-Get Hygiene

These two rules work together. DL3008 requires pinning versions; DL3009 requires cleaning lists to reduce layer size:

# Bad — unpinned, no cleanup, two RUN layers
RUN apt-get update
RUN apt-get install -y curl

# Good — pinned, cleaned, single layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      curl=7.88.1-1+deb12u1 \
      ca-certificates=20230311+deb12u1 && \
    rm -rf /var/lib/apt/lists/* && \
    apt-get clean

DL3025 — JSON CMD/ENTRYPOINT

String-form CMD invokes a shell (/bin/sh -c), which breaks signal forwarding and adds process overhead. JSON form runs directly:

# Bad — shell form, PID 1 is /bin/sh, not your app
CMD echo "Starting" && ./app

# Good — exec form, PID 1 is your app, receives SIGTERM
CMD ["/app", "--config", "/etc/config.yaml"]

DL3059 — Consolidated RUN Instructions

Every RUN creates a new layer. Multiple consecutive RUN lines waste space and cache efficiency:

# Bad — three layers, three separate apt operations
RUN apt-get update
RUN apt-get install -y nginx
RUN apt-get clean

# Good — single layer, atomic operation
RUN apt-get update && \
    apt-get install -y --no-install-recommends nginx=1.24.0-1 && \
    rm -rf /var/lib/apt/lists/* && \
    apt-get clean

DL4006 — ShellCheck Integration

hadolint runs ShellCheck on every RUN instruction's shell script. Common catches:

# ShellCheck SC2046 — word splitting on $(ls)
RUN for f in $(ls *.txt); do cat "$f"; done

# Fixed — use glob expansion or find -print0
RUN for f in *.txt; do cat "$f"; done

DL3042 — Pip Cache Discipline

Python images can balloon by 200MB+ from pip caches:

# Bad — cached pip downloads persist in the layer
RUN pip install flask gunicorn

# Good — cache cleaned, packages pinned
RUN pip install --no-cache-dir \
      flask==3.0.2 \
      gunicorn==21.2.0

Advanced Configuration Patterns

Multi-Stage Build Linting

hadolint handles multi-stage builds seamlessly. Each stage is analyzed independently, and rules like DL3022 (COPY from unknown stage) catch cross-stage reference errors:

# Multi-stage Dockerfile — hadolint validates stage names and references
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /server ./cmd/server

FROM alpine:3.19.1
COPY --from=builder /server /usr/local/bin/server
# hadolint checks that 'builder' stage exists and produces /server
CMD ["/usr/local/bin/server"]

Inline Rule Suppression

Sometimes you need to bypass a rule for a specific line — hadolint supports inline ignore comments:

# hadolint ignore=DL3007
FROM ubuntu:latest

# hadolint ignore=DL3008,DL3009
RUN apt-get update && apt-get install -y curl

# Multiple rules on one line
RUN apt-get install -y --no-install-recommends python3  # hadolint ignore=DL3018,DL3042

Use inline suppressions sparingly and always document the reason in a comment adjacent to the ignore directive.

Custom Dockerfile Naming Conventions

Many projects use non-standard Dockerfile names. hadolint handles these gracefully:

# Lint files with .dockerfile extension
hadolint *.dockerfile

# Lint files in nested directories
find services -name 'Dockerfile*' -exec hadolint {} +

# Lint all Dockerfiles including those with suffixes
hadolint Dockerfile Dockerfile.dev Dockerfile.prod

Combining Linting with BuildKit Syntax Checks

Modern Docker (23.0+) includes built-in validation when using BuildKit. Combine both for defense in depth:

# Step 1: Lint with hadolint
hadolint --config .hadolint.yaml --failure-threshold error Dockerfile

# Step 2: Validate syntax with Docker BuildKit dry-run
DOCKER_BUILDKIT=1 docker build --check . -f Dockerfile

# Step 3: Full build with BuildKit attestations
DOCKER_BUILDKIT=1 docker build \
  --provenance=true \
  --sbom=true \
  -t myapp:latest .

The --check flag performs a validation pass without actually building, catching syntax errors and invalid COPY references that hadolint might not detect.

Best Practices for Production-Grade Linting

Complete Example: Production-Ready Dockerfile with Lint Configuration

Here is a realistic, lint-clean Dockerfile for a Python web service, accompanied by its hadolint configuration:

# Dockerfile — Python FastAPI service
# hadolint passes clean with .hadolint.yaml below

# === Build Stage ===
FROM python:3.12.2-slim@sha256:abc123def456... AS builder

WORKDIR /build

# Install build dependencies with pinned versions
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends \
      build-essential=12.9 \
      libpq-dev=15.1-1+deb12u1 && \
    rm -rf /var/lib/apt/lists/*

# Copy dependency definitions first for layer caching
COPY requirements.txt ./
RUN pip install --no-cache-dir --no-deps -r requirements.txt

# Copy application code
COPY src/ ./src/
COPY pyproject.toml ./

# Install the application as a wheel
RUN pip install --no-cache-dir .

# === Runtime Stage ===
FROM python:3.12.2-slim@sha256:def456ghi789...

# Create non-root user
RUN groupadd -r appgroup -g 1000 && \
    useradd -r -u 1000 -g appgroup -m -s /bin/bash appuser

# Copy only the installed packages and app from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages/ \
    /usr/local/lib/python3.12/site-packages/
COPY --from=builder /usr/local/bin/ /usr/local/bin/

# Runtime dependencies only
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends \
      libpq5=15.1-1+deb12u1 \
      curl=7.88.1-1+deb12u1 && \
    rm -rf /var/lib/apt/lists/*

USER appuser
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1

ENTRYPOINT ["/usr/local/bin/uvicorn"]
CMD ["app.main:app", "--host", "0.0.0.0", "--port", "8080"]

# Metadata labels
LABEL org.opencontainers.image.title="fastapi-service"
LABEL org.opencontainers.image.version="1.5.0"
LABEL org.opencontainers.image.maintainer="team-platform@example.com"
LABEL com.example.git-sha="a1b2c3d"

And the accompanying configuration that validates this Dockerfile:

# .hadolint.yaml — Configuration for the above Dockerfile

failure-threshold: warning

ignored:
  # We use --no-deps in the builder to avoid re-downloading,
  # which intentionally bypasses the pip pinning rule for
  # the transitive dependency set. Documented and reviewed.
  - DL3042

override:
  severity:
    # DL3007 (latest tag) is unacceptable — fail the build
    - DL3007: error
    # DL3059 wants single RUN; we use RUN --mount which is
    # intentionally split for cache semantics
    - DL3059: style

trusted-registries:
  - docker.io
  - ghcr.io

label-schema:
  org.opencontainers.image.title:
    required: true
  org.opencontainers.image.version:
    required: true
  org.opencontainers.image.maintainer:
    required: true
  com.example.git-sha:
    required: false

strict-labels: false

shell: bash

Conclusion

Dockerfile linting transforms image builds from a source of surprises into a predictable, auditable, and secure process. By integrating hadolint — with a carefully tuned .hadolint.yaml — into your editor, pre-commit hooks, and CI/CD pipelines, you catch structural flaws, security anti-patterns, and size bloat before they compound across environments. The configuration surface is deliberately small but powerful: ignore lists, severity overrides, registry whitelists, label schemas, and shell dialect selection give you precise control without overwhelming ceremony. Start with the strictest defaults, pin your base images to digests, treat DL3007 as a hard error, and let the linter guide your team toward Dockerfiles that are lean, reproducible, and production-safe from the very first FROM line.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles