Hadolint is the fastest check in the container chapter: it reads the Dockerfile as text, needs no daemon, no build, and no image, and returns in milliseconds.
It is also the one most likely to give you false confidence, so this lesson is as much about what it misses as what it finds.
Verified against Hadolint 2.14.0, against the Range Dockerfile, which carries six deliberate flaws. Output copied from a real terminal.
Step 1 — Install and run
brew install hadolint
hadolint --versiongit clone https://github.com/jaybilgaye/aiopsone-range
cd aiopsone-range
hadolint docker/Dockerfiledocker/Dockerfile:35 DL3008 warning: Pin versions in apt get install.
Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
docker/Dockerfile:35 DL3009 info: Delete the apt lists (/var/lib/apt/lists) after installing something
docker/Dockerfile:35 DL3015 info: Avoid additional packages by specifying `--no-install-recommends`Three findings. All on line 35. All about apt-get.
Step 2 — Now count what it did not find
The Range Dockerfile is annotated with six numbered flaws. Compare:
| Planted flaw | Hadolint |
|---|---|
End-of-life base image (node:18, EOL April 2025) |
not flagged |
AWS credentials baked into ENV layers |
not flagged |
COPY . . pulling in .git and everything else |
not flagged |
Unpinned apt-get install |
DL3008 ✓ |
| Apt lists left in the layer | DL3009 ✓ |
No --no-install-recommends |
DL3015 ✓ |
No USER — the container runs as root |
not flagged |
No HEALTHCHECK |
not flagged |
Three out of eight, and it missed every one that matters for security. Root user, hardcoded credentials, and an unsupported base are the three findings you would actually escalate, and Hadolint reports none of them.
This is not a bug — it is the tool's scope. Hadolint is a linter: it checks the Dockerfile against style and best-practice rules, mostly shell hygiene and layer efficiency. It is not a security scanner, it does not build the image, and it cannot see anything that only exists after a build. Treating "hadolint passed" as "the image is fine" is the mistake.
One nuance on the base image. DL3006 requires a tag rather than latest — and node:18 has a tag, so it passes. The tag is EOL and floats to whatever 18 currently resolves to, but the rule is satisfied. A rule can be green while the underlying decision is wrong.
Step 3 — Where the missing findings come from
Each blind spot is covered by a different tool later in this chapter:
| Missed by Hadolint | Caught by | How |
|---|---|---|
| Runs as root | trivy config DS-0002 (HIGH) |
reads the Dockerfile, no build needed |
Secrets in ENV layers |
trivy config DS-0031 (CRITICAL ×3) |
flags known credential env names |
No HEALTHCHECK |
trivy config DS-0026 (LOW) |
same pass |
| End-of-life base, OS CVEs | Trivy image, Grype | resolves installed packages against CVE feeds |
COPY . . pulling in .git |
a .dockerignore review |
nothing lints this well |
Verified — the same file through trivy config docker/Dockerfile:
Failures: 7 (LOW: 1, MEDIUM: 1, HIGH: 2, CRITICAL: 3)
DS-0002 (HIGH): Specify at least 1 USER command with a non-root user
DS-0026 (LOW): Add HEALTHCHECK instruction in your Dockerfile
DS-0031 (CRITICAL): Possible exposure of secret env "AWS_ACCESS_KEY_ID" in ENV
DS-0031 (CRITICAL): Possible exposure of secret env "AWS_SECRET_ACCESS_KEY" in ENV
DS-0031 (CRITICAL): Possible exposure of secret env "DB_PASSWORD" in ENVSeven findings against Hadolint's three, on the same file, with no image built — including all three of the security ones Hadolint missed. If you only run one Dockerfile check, it should not be Hadolint.
That is the actual argument for a layered container pipeline: lint the file, scan the image, then scan what the image contains. Hadolint is the cheapest of the three and the least informative about risk, which is exactly why it goes first and never last.
Step 4 — Gate it
Hadolint is fast enough to run on every push with no caching:
# .github/workflows/container.yml
name: container
on: [pull_request]
permissions:
contents: read
jobs:
hadolint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: docker/Dockerfile
failure-threshold: warning # info-level findings report but do not blockfailure-threshold: warning means DL3008 (warning) blocks and the two info findings do not. Without it the default fails on everything including style notes, which is how a container gate gets disabled in week two.
Step 5 — Ignore a rule, in the file, with a reason
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y curlScoped to the next instruction, naming the rule. A repo-level .hadolint.yaml ignore list is available and worth avoiding — a global ignore silences a rule everywhere, including in Dockerfiles written after you left.
Stretch: fix all three, then re-run
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl=7.88.1-10+deb12u* \
&& rm -rf /var/lib/apt/lists/*Pinned, no recommends, lists removed. hadolint docker/Dockerfile now returns nothing — and the image still runs as root with credentials in its layers. A clean lint on a dangerous image is the most useful thing this exercise can show you.
What you learned
- Hadolint found 3 of 8 planted flaws, all of them
apt-gethygiene. - It missed root user, baked-in credentials, and the EOL base — the three you would actually escalate.
trivy configcatches the first two on the same file, without a build. - A rule can pass while the decision is wrong:
node:18satisfies the "use a tag" rule and is still end-of-life. - Lint the file, scan the image, scan the contents. Hadolint is step one of three and cannot substitute for the other two.
failure-threshold: warning, and ignore inline by rule id with a reason.