Skip to main content

Lint the Dockerfile Before You Build It: What hadolint, dockle, and Docker Scout Catch That a Green Build Never Will

8 min readDora NodaDora Noda
Share
On this page

docker build exiting zero tells you exactly one thing: the instructions assembled into an image. It says nothing about whether the base tag will resolve to the same bytes next week, whether the container runs as root, whether a secret got baked into a layer, or whether the packages inside carry known CVEs. Every one of those ships silently inside a green build.

And the corpus numbers say most teams never check. A Prevasio scan of four million Docker Hub images found 51% contained at least one critical vulnerability. NetRise's container analysis found 600+ vulnerabilities on average per container plus 4.8 misconfigurations each — world-writable directories, overly permissive identity controls, the works.

The fix is not a bigger scanner at the end. It is three small gates at three different pipeline stages, each catching a failure class the others cannot see: hadolint lints the Dockerfile source at PR time, dockle audits the built image against CIS benchmarks at build time, and Docker Scout gates on CVEs and policy at deploy time. This post shows what each gate catches with concrete rules and copy-pasteable configs, where each belongs in a self-hosted git-push pipeline, and why a rejected Dockerfile costs seconds while a deployed unaudited image costs an incident.

Gate 1: hadolint at PR time — lint the recipe, not the meal

hadolint is a Haskell Dockerfile linter that parses the Dockerfile itself — no build required, runs in milliseconds, and embeds ShellCheck so RUN lines get shell linting too. That makes it a PR-time gate: it reviews the recipe before anyone pays for the meal. Its rules read like a greatest-hits list of things a green build waves through:

  • DL3006 / DL3007 — pin the base image, never :latest. FROM node:latest builds fine today and silently becomes a different operating system next month. hadolint flags both the missing tag and the floating one; the fix is FROM node:22.14.0-bookworm-slim, or better, a digest pin.
  • DL3008 / DL3018 / DL3013 — pin installed packages. apt-get install -y curl resolves to whatever the mirror serves that day. Pinning (curl=8.5.0-2ubuntu10.6) plus --no-install-recommends and rm -rf /var/lib/apt/lists/* (DL3009) keeps builds reproducible and layers small.
  • DL3020 — COPY, not ADD, for plain files. ADD's magic URL-fetching and tarball-extraction behavior is a supply-chain surprise waiting to happen; hadolint steers you to COPY unless you genuinely need the magic.
  • DL3025 — JSON form for CMD/ENTRYPOINT. Shell form wraps your process in /bin/sh -c, which breaks signal forwarding — your app never receives SIGTERM cleanly, and graceful shutdown on a PaaS becomes a 30-second timeout followed by SIGKILL.
  • SC2086 and friends — ShellCheck inside RUN. Unquoted variables, useless cat, cd without effect across layers: the shell mistakes that make builds flaky get flagged with the same codes ShellCheck users already know.

A before/after shows the density of findings in a typical hand-written Dockerfile:

dockerfile
# Before: builds green, lints red
FROM node:latest
ADD . /app
RUN apt-get update && apt-get install -y curl python3
CMD npm start
dockerfile
# After: reproducible and signal-clean
FROM node:22.14.0-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN apt-get update \
  && apt-get install -y --no-install-recommends curl=8.5.0-* python3=3.11* \
  && rm -rf /var/lib/apt/lists/*
COPY . .
USER node
CMD ["npm", "start"]

Wire it as a PR check with a project config so the rule set is versioned alongside the code:

yaml
# .hadolint.yaml
failure-threshold: warning
trustedRegistries:
  - docker.io
  - ghcr.io
ignored:
  - DL3008 # allow unpinned apt versions in dev-only Dockerfiles
bash
hadolint Dockerfile
# or without installing anything:
docker run --rm -i hadolint/hadolint < Dockerfile

Fail the PR on any warning in the never-ignore list — DL3000, DL3007, DL3008, DL3018, DL3025, DL3027, DL3059, DL4006 is a solid starting set — and a bad Dockerfile dies in seconds, with a line number, before it ever consumes build minutes.

Gate 2: dockle at build time — audit the image, not the source

hadolint sees only the Dockerfile. It cannot see what the base image itself contains: the setuid binaries, the default root user, the secrets a previous stage leaked into a layer. dockle (originally by GoodWithTech, now community-maintained) closes that gap by auditing the built image against CIS Docker benchmark checkpoints. It runs after docker build, takes an image name, and reports FATAL/WARN/INFO/PASS per checkpoint:

bash
dockle 'myapp:abc1234'
text
FATAL   - CIS-DI-0001: Create a user for the container
        * Last user should not be root
WARN    - CIS-DI-0005: Enable Content trust for Docker
FATAL   - CIS-DI-0008: Remove setuid and setgid permissions in the images
        * Found setuid file: usr/lib/openssh/ssh-keysign
PASS    - CIS-DI-0009: Use COPY instead of ADD in Dockerfile
PASS    - CIS-DI-0010: Do not store secrets in ENVIRONMENT variables
PASS    - DKL-DI-0001: Avoid sudo command

The checkpoints that matter most for a multi-tenant PaaS:

  • CIS-DI-0001 — the container must not run as root. A root process that escapes the container (see: the Leaky Vessels escape chain) owns the host. On shared node pools this is the difference between a tenant compromise and a fleet compromise.
  • CIS-DI-0008 — strip setuid/setgid bits. Inherited from base images far more often than authored; find / -perm -4000 in a fat base image is routinely surprising.
  • CIS-DI-0010 — no secrets in env vars or files. ENV AWS_SECRET_KEY=... persists in image metadata forever, visible to anyone who can pull the image. dockle flags both the env form and secret-looking files left in layers.
  • DKL-DI-0001 / DKL-DI-0002 — no sudo, no sensitive mounts. A production image has no business containing privilege-escalation tooling.

dockle exits non-zero on FATAL findings, so the build-time gate is one line after the build step:

bash
docker build -t "myapp:${SHA}" .
dockle --exit-code 1 --exit-level FATAL "myapp:${SHA}"

Note the deliberate layering: hadolint would have approved a Dockerfile that COPYs cleanly onto a base image shipping ssh-keysign setuid — only an image-level audit sees inherited sins. Each gate covers the other's blind spot.

Gate 3: Docker Scout at deploy time — CVEs and policy on the artifact you ship

Source lint and CIS checks say nothing about whether libssl3 inside the image has a known critical CVE published last Tuesday. Docker Scout answers that by matching the image's SBOM against vulnerability feeds (NVD, GitHub Advisories) and evaluating policy — and it belongs at deploy time, against the exact artifact digest about to go live, because CVE data changes daily while Dockerfiles change rarely.

Three commands form the gate:

bash
# 1. Overview: base-image freshness, known vulns at a glance
docker scout quickview "myapp:${SHA}"
 
# 2. CVE gate: fail on fixable critical/high CVEs (exit code 2 on match)
docker scout cves "myapp:${SHA}" \
  --only-severity critical,high \
  --only-fixed \
  --exit-code
 
# 3. Policy gate: org policy incl. attestation coverage
docker scout policy --exit-code "myapp:${SHA}"

Two flags deserve emphasis. --only-fixed keeps the gate actionable: builds fail only on CVEs a patch actually exists for, so teams are never blocked by an unfixable advisory with no remediation path. And --exit-code returns 2 (not 1) when findings match, which matters for scripts using set -e — capture the code explicitly rather than letting the shell abort the step before you record the result.

For CVEs you have triaged and accepted — a vulnerable package that is provably unreachable at runtime, the case Sysdig's data says covers the large majority of flagged-but-unexploitable findings — record the decision as a VEX (Vulnerability Exploitability eXchange) statement rather than weakening the gate. Scout's policy evaluation understands VEX attestations, so the waiver is documented, scoped, and auditable instead of a --ignore flag nobody remembers adding.

In GitHub Actions the whole deploy-time gate is the official action:

yaml
- name: Docker Scout scan
  uses: docker/scout-action@v1
  with:
    command: cves
    image: myapp:${{ github.sha }}
    severity: high,critical
    exit-code: true

Run this against the digest you promote, not a floating tag — the gate must attest the bytes that actually ship.

Where each gate lives: one table

The three tools overlap in spirit ("don't ship bad images") but not in coverage. The placement that respects what each one can actually see:

StageToolInputFailure class it ownsCost of catching it one stage later
PR timehadolintDockerfile sourceUnpinned bases/packages, ADD misuse, shell-form CMD, shell bugsWasted build minutes on an image that was wrong before it built
Build timedockleBuilt imageRoot user, setuid binaries, baked-in secrets, sudo — incl. inherited from baseA deployable artifact carrying privilege-escalation paths into the registry
Deploy timeDocker ScoutImage digest + live CVE feedsKnown CVEs, stale base, policy/attestation gapsA running production container with a published exploit, i.e. an incident

Two placement notes. First, Scout can run earlier, but CVE data refreshes continuously — scanning at PR time and deploying three days later re-opens the window the scan closed. The deploy-time run is the one that counts; earlier runs are advisory.

Second, this layering composes with SBOM-at-admission: hadolint keeps the recipe honest, dockle keeps the artifact's shape honest, Scout keeps its contents honest, and an admission controller verifying signatures and SBOMs at schedule time keeps the cluster honest. No single gate replaces the others.

A rejected Dockerfile costs seconds; a deployed image costs an incident

Put a price on each outcome and the pipeline designs itself. A hadolint rejection costs the author seconds and a line number. A dockle rejection costs a rebuild — minutes, contained, pre-registry. A Scout rejection at deploy time costs a base-image bump and a re-run — annoying, but still pre-production.

A CVE or root-run container discovered in production costs triage, emergency rebuilds, forced tenant restarts, and a postmortem — orders of magnitude more, with blast radius.

The uncomfortable corollary for git-push platforms: if your pipeline builds whatever the tenant pushed and deploys whatever built, you have automated the green-build fallacy at scale. Every tenant Dockerfile becomes unaudited infrastructure on your shared nodes. The three gates above are cheap enough to run on every push — hadolint is milliseconds, dockle is seconds, Scout is one API call against data Docker already indexes — which makes them the rare security control that gets cheaper per tenant as the platform grows: configure once, enforce for every app on the fleet.

Start with hadolint on every PR this week. Add dockle to the build step next. Put Scout on the deploy path before your next base-image refresh. Three gates, three failure classes, zero green builds that lie to you.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex