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:
- hadolint — Full-featured CLI linter with configurable rules, severity levels, and CI/CD integrations
- docker-lint — A lightweight, rule-extensible linter written in Python
- Docker BuildKit checks — Built-in validation hooks available in modern Docker versions via
DOCKER_BUILDKIT=1 - Super-Linter — GitHub Action that bundles hadolint alongside linters for dozens of other languages
- Trivy — While primarily a vulnerability scanner, Trivy includes Dockerfile misconfiguration checks
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:
- Security vulnerabilities — Using
latesttags, running as root, or exposing secrets in build arguments - Bloated images — Not cleaning package manager caches after installs, which can add hundreds of megabytes
- Broken caching — Placing frequently-changing files before stable dependencies in
COPYchains - Non-deterministic builds — Relying on external URLs without pinning versions or checksums
- Compliance violations — Failing to meet organizational policies around base images, labels, or users
- Slow CI pipelines — Unoptimized layer ordering that triggers full rebuilds on every commit
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
-
Start strict, then selectively relax. Begin with
failure-threshold: infoand zero ignored rules. Only add exceptions after team discussion and with a comment trail in.hadolint.yamlexplaining each ignore. - Pin base images to digests. Tags are mutable pointers; digests are immutable content hashes. A pinned digest guarantees bit-for-bit identical builds across time and infrastructure — critical for compliance and debugging.
- Run linting at three stages: in the editor (real-time feedback), as a pre-commit hook (gate before commit), and in CI/CD (policy enforcement). Each stage catches different classes of problems.
-
Version-lock your linter. Use explicit hadolint versions in CI (e.g.,
hadolint/hadolint:v2.12.0) rather thanlatestto prevent surprise rule additions breaking your pipeline. -
Treat DL3007 (latest tag) as an error, not a warning. Override its severity in
.hadolint.yamltoerror— this single rule prevents more production incidents than any other. - Combine hadolint with vulnerability scanning. Linting catches structural issues; Trivy or Grype catch CVEs in dependencies. Together they form a complete image security pipeline.
-
Document your label schema. If using
label-schemavalidation, publish the required labels in your project README so contributors know what to add before their Dockerfile passes lint. -
Use JSON output for dashboards. Pipe
hadolint --format jsoninto Elasticsearch, Grafana, or custom policy engines to track lint health across repositories over time. -
Never disable ShellCheck rules globally. DL4000–DL4006 catch real shell bugs like unquoted variable expansion and incorrect
$!usage. Suppress inline only when absolutely necessary. -
Keep
.hadolint.yamlin version control. It's part of your project's build contract and should be reviewed in pull requests alongside the Dockerfile changes it governs.
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.