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:latestbuilds fine today and silently becomes a different operating system next month. hadolint flags both the missing tag and the floating one; the fix isFROM node:22.14.0-bookworm-slim, or better, a digest pin. - DL3008 / DL3018 / DL3013 — pin installed packages.
apt-get install -y curlresolves to whatever the mirror serves that day. Pinning (curl=8.5.0-2ubuntu10.6) plus--no-install-recommendsandrm -rf /var/lib/apt/lists/*(DL3009) keeps builds reproducible and layers small. - DL3020 —
COPY, notADD, for plain files.ADD's magic URL-fetching and tarball-extraction behavior is a supply-chain surprise waiting to happen; hadolint steers you toCOPYunless 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, uselesscat,cdwithout 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:
# Before: builds green, lints red
FROM node:latest
ADD . /app
RUN apt-get update && apt-get install -y curl python3
CMD npm start# 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:
# .hadolint.yaml
failure-threshold: warning
trustedRegistries:
- docker.io
- ghcr.io
ignored:
- DL3008 # allow unpinned apt versions in dev-only Dockerfileshadolint Dockerfile
# or without installing anything:
docker run --rm -i hadolint/hadolint < DockerfileFail 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:
dockle 'myapp:abc1234'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 commandThe 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 -4000in 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:
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:
# 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:
- name: Docker Scout scan
uses: docker/scout-action@v1
with:
command: cves
image: myapp:${{ github.sha }}
severity: high,critical
exit-code: trueRun 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:
| Stage | Tool | Input | Failure class it owns | Cost of catching it one stage later |
|---|---|---|---|---|
| PR time | hadolint | Dockerfile source | Unpinned bases/packages, ADD misuse, shell-form CMD, shell bugs | Wasted build minutes on an image that was wrong before it built |
| Build time | dockle | Built image | Root user, setuid binaries, baked-in secrets, sudo — incl. inherited from base | A deployable artifact carrying privilege-escalation paths into the registry |
| Deploy time | Docker Scout | Image digest + live CVE feeds | Known CVEs, stale base, policy/attestation gaps | A 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.



