Secure the PipelineContainerStep 20 of 31

Lint Your Dockerfile Before You Build (Hadolint)

difficulty beginnerhands-on 15 min hands-on

65% complete

prereqs · docker-and-images

concepts · Dockerfile linting · tool scope · layer hygiene · defence in depth

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

bash
brew install hadolint
hadolint --version
bash
git clone https://github.com/jaybilgaye/aiopsone-range
cd aiopsone-range

hadolint docker/Dockerfile
text
docker/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:

text
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 ENV

Seven 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:

yaml
# .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 block

failure-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

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

Scoped 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

dockerfile
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-get hygiene.
  • It missed root user, baked-in credentials, and the EOL base — the three you would actually escalate. trivy config catches the first two on the same file, without a build.
  • A rule can pass while the decision is wrong: node:18 satisfies 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.