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-getpackages without versions leads to non-reproducible builds - Using
latesttags creates silent breaks when upstream images update - Running as root in containers unnecessarily expands the attack surface
- Not combining
RUNinstructions creates unnecessary image layers - Missing
HEALTHCHECKinstructions 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 < DockerfileBasic 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 DockerfileCommon 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-1DL3009 — 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 warningSC2086 — 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:
- DL3008Or inline in Dockerfile comments:
# hadolint ignore=DL3008
RUN apt-get install -y python3GitHub 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.sarifSARIF 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"
doneSecurity-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 | bashDL3020 — 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.gzCombining 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.sarifTesting 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 DockerfileFor 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.yamlfor team-wide rules; use inline# hadolint ignore=for exceptions --failure-threshold errorallows 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) andDL4006(pipefail) are the most security-critical rules