Most Dockerfile mistakes don't fail your build. They fail at 2 a.m., in production, when the image you shipped turns out to be running as root, pulling :latest that moved overnight, or running an installer you never verified.

Here's how to catch them before the build — the seven checks I run, with a real example.

The example: a Dockerfile with everything wrong

This is a small, realistic Dockerfile. It builds fine. Every line below is a problem:

FROM ubuntu RUN apt-get update RUN apt-get install -y curl ADD . /app RUN curl https://example.com/install.sh | sh FROM node:latest 

The 7 checks

1. Unpinned base image. FROM ubuntu has no tag, so it silently resolves to latest. Pin it: ubuntu:22.04, or a digest if you want it to never move.

2. :latest tag. FROM node:latest moves over time. The image you tested last month is not the image you build today.

3. Download-to-shell (critical). curl ... | sh pipes a remote script straight into a shell. If that server is compromised — or the connection is intercepted — you just ran attacker's code inside your build. This is the one finding that should block a build.

4. apt-get update without install. RUN apt-get update in its own layer leaves a stale package cache. Combine it with the install in the same RUN.

5. apt-get install without cleanup. Without rm -rf /var/lib/apt/lists, the package lists stay in the image layer and bloat it.

6. ADD where COPY would do. ADD also fetches URLs and extracts archives. For plain local files, COPY does exactly what you mean and nothing else.

7. No USER instruction. Without one, the container runs as root by default. Add a USER — even nobody is better than root.

The real run

I ran a linter over that Dockerfile. Verbatim output:

$ python3 scripts/dockerfile_lint.py check --dockerfile Dockerfile.danger FAIL: 7 finding(s), 1 at or above critical [warning] unpinned-base (line 1): base image has no tag and resolves to latest implicitly [warning] apt-update-alone (line 2): apt-get update without install in the same layer leaves a stale cache [warning] apt-no-cleanup (line 3): apt-get install without removing /var/lib/apt/lists bloats the image [warning] add-not-copy (line 4): ADD used; prefer COPY for plain local files (ADD also fetches URLs and extracts archives) [critical] download-to-shell (line 5): RUN pipes a download into a shell interpreter [warning] latest-tag (line 6): base image explicitly uses the latest tag, which moves over time [warning] no-user (USER): no USER instruction; the image runs as root by default 

Exit code 1 — it fails the gate. The same tool on a clean Dockerfile prints PASS: 0 finding(s) and exits 0.

The fixed version

Same intent, all seven findings gone:

FROM ubuntu:22.04 RUN apt-get update && apt-get install -y curl \  && rm -rf /var/lib/apt/lists COPY . /app USER nobody 

I verified this passes: PASS: 0 finding(s), 0 at or above critical. The curl | sh line is gone entirely — install the tool from your package manager or a verified release instead of piping a remote script into a shell.

Making it routine

Check before every build, not when something breaks:

python3 scripts/dockerfile_lint.py check --dockerfile Dockerfile 
  • Default --fail-on critical means a critical finding blocks the build while warnings stay advisory. Use --fail-on warning for a stricter gate.
  • Add --json-out report.json --md-out report.md if you want a report file for CI.
  • It runs offline and never builds or pulls an image, so it's fast enough to run on every commit.

What this doesn't do

Honest limits: it doesn't build or run the image, doesn't check whether a base image or package actually exists, and doesn't scan for known vulnerabilities. It's a structural check — the cheap gate before the expensive checks.

The tool

The linter above is Dockerfile Linter — 7 checks, offline, $9.99 once, no subscription. If you'd rather not do this by hand every time, it's there.