Linting Basics
This section covers linting Dockerfiles with hadolint: the core rules, how to run it locally and in CI, and how to encode team exceptions so warnings stay meaningful.
Prerequisites
- Docker Engine 29.x with BuildKit as the default backend.
- hadolint - the Haskell Dockerfile linter, which wraps ShellCheck for
RUNscripts. - A Dockerfile to lint and a CI runner that can execute a container or binary.
Quick install on macOS:
brew install hadolintOr run it as a container, which needs no local install:
docker run --rm -i hadolint/hadolint < DockerfileBasic Examples
1. Lint a single Dockerfile
Run hadolint against a file and read the numbered rule codes.
hadolint Dockerfile- Output lines look like
Dockerfile:5 DL3008 warning: Pin versions in apt install. DLcodes are hadolint's own rules;SCcodes come from the embedded ShellCheck.- Exit code is non-zero when any finding at or above the failure threshold exists.
- No build happens, so this returns in well under a second.
2. Fail CI only on errors
Set a threshold so warnings inform without blocking every merge.
hadolint --failure-threshold error Dockerfile- Valid levels are
error,warning,info, andstyle. errorblocks on the most serious rules while letting style hints through.- Tighten the threshold over time as the team cleans up existing warnings.
- Combine with a required CI check so a failure stops the pipeline.
3. Pin apt package versions (DL3008)
The most common early finding is unpinned apt packages.
# Flagged by DL3008
RUN apt-get update && apt-get install -y curl
# Passes DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
curl=7.88.1-10+deb12u* \
&& rm -rf /var/lib/apt/lists/*- Pinning versions makes rebuilds reproducible.
--no-install-recommendstrims incidental packages and image size.- Cleaning
/var/lib/apt/lists/*in the same layer avoids shipping the apt cache. - The two commands stay in one
RUNso the cleanup actually shrinks the layer.
4. Avoid the latest tag (DL3007)
Floating base tags break reproducibility and provenance.
# Flagged by DL3007
FROM node:latest
# Preferred - pin to a digest
FROM node:22.11.0-bookworm-slim@sha256:<digest>latestcan change under you between two identical builds.- A digest is content-addressed and immutable.
- Pinning is what makes a later scan or signature meaningful.
- Keep the human-readable tag next to the digest for readability.
5. Use absolute WORKDIR (DL3000)
Relative working directories are ambiguous across stages.
# Flagged by DL3000
WORKDIR app
# Passes
WORKDIR /app- An absolute path is unambiguous regardless of the previous instruction.
- It prevents surprises when a base image sets its own
WORKDIR. - Downstream
COPYtargets become predictable. - This is a cheap fix with no runtime cost.
6. Prefer COPY over ADD (DL3020)
ADD has surprising behavior that COPY avoids.
# Flagged by DL3020 for a local file
ADD ./app.jar /app/app.jar
# Passes
COPY ./app.jar /app/app.jarADDauto-extracts local tar archives and can fetch URLs, which is easy to misuse.COPYdoes exactly one thing, so intent is clear.- Reserve
ADDfor the rare case where tar extraction is deliberate. - Clear intent makes the Dockerfile easier to review.
7. Set a non-root USER (DL3002 family)
Running as root is a security default worth catching in the linter.
RUN useradd --uid 10001 --create-home appuser
USER 10001- A numeric UID lets Kubernetes verify
runAsNonRootwithout resolving names. - Setting
USERbefore the entrypoint means the process never starts as root. - This pairs with a cluster policy that rejects root containers.
- Keep the UID above 10000 to avoid clashing with host users.
Intermediate Examples
8. Inline ignores with justification
Silence a single finding where the rule genuinely does not apply.
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y build-essential- The
ignorecomment applies only to the next instruction. - Always leave a comment explaining why the exception is safe.
- Prefer inline ignores over broadening a global threshold.
- Reviewers can see the exception in the diff.
9. A shared team exceptions file
Encode standing exceptions once in .hadolint.yaml at the repo root.
failure-threshold: warning
ignored:
- DL3059 # multiple consecutive RUN - allowed for readability here
trustedRegistries:
- registry.example.com
- docker.io
override:
error:
- DL3007 # never allow :latest- hadolint auto-discovers
.hadolint.yamlin the working directory. trustedRegistriesfails builds that pull from unapproved sources.overridepromotes chosen rules to hard errors for the whole team.- Checking the file into git makes the policy reviewable and versioned.
10. Wire hadolint into CI
Run the linter as a required, early gate before the build.
name: image-lint
on: [pull_request]
jobs:
hadolint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint Dockerfile
run: docker run --rm -i hadolint/hadolint hadolint - < Dockerfile- Running from the container image pins the linter version for reproducibility.
- Placing it on
pull_requestgives feedback before merge. - The step exits non-zero on findings, which fails the check.
- Keep it separate from the build job so it fails fast.
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).