Skip to content

Dockerfile Linter · Docker

Lint a Dockerfile before it reaches review.

Seventeen rules over a real parse of the file: the apt-get update that will serve a stale index, the secret written into your image history, the exec form Docker quietly ran as a shell string. Every finding names a line and carries the fix — and nothing you paste leaves the tab.

Runs in your browser — nothing you paste leaves this page. How we prove that

Runs in your browser 17 rules, DF001–DF017 No signup Updated Jul 31, 2026

Dockerfile Linter playground

Examples

A build stage is one FROM and every instruction under it; a multi-stage file has several, and only the last one ships. The layer cache reuses an instruction's result until that instruction — or anything it copied — changes.

Dockerfile input

Results update as you type — press Enter to run now.

Press Esc to release keyboard focus from the editor; /Ctrl + Enter lints and leaves the editor. Tap a Line badge to jump to that line. Nothing you paste is uploaded.

Findings

Paste a Dockerfile above — or tap an example — to see line-numbered findings here, each with the reason it matters and the fix.

The Gap

A Dockerfile that builds is not a Dockerfile that is right.

Every mistake in the catalog below builds cleanly. FROM node builds. apt-get update in its own layer builds — and then installs from an index that has been cached since last quarter. CMD ['nginx'] builds, and Docker quietly runs /bin/sh -c "['nginx']" instead of the process you meant to be PID 1. A green build is not evidence; it is the absence of evidence.

Ask an assistant to write the Dockerfile and you inherit the same class of bug with more confidence attached. Generated Dockerfiles are fluent and plausible: they pass review because they look like every other Dockerfile, and they carry DF007 and DF014 shaped defects that no build failure will ever point at. Checking the claim takes seconds; the hard part is knowing what to check.

hadolint is the right answer in CI, and this is not a replacement for it. This is the case it does not cover: a file you want checked now, from a browser, on a machine where you cannot install a binary, without sending a Dockerfile full of internal registry hosts and build arguments to somebody else's server. Seventeen rules, a fix on every finding, and an explicit list of what stays silent — because a linter you cannot audit is a linter you have to trust.

Already have a container running? Docker Run to Compose turns the command into a service file, and Env Example Checker catches the variables your image expects but your environment never sets.

The Pipeline

How it works.

Four steps, all inside your browser tab, re-run as you type.

  1. Parse it like BuildKit.

    Continuations, whole-line comments inside them, heredoc bodies, the JSON exec form, stages and the pre-FROM ARG table. A `# escape=` directive really does flip the continuation character.

  2. Analyse the parse, not the text.

    Seventeen rules read instructions and stages. Shell rules walk a quote-aware scan of each RUN, so a pipe inside a quoted string is not a pipe and a heredoc body is still shell.

  3. Report a line and a fix.

    Every finding names the PHYSICAL line — line 4 of a folded RUN, not the line the RUN started on — and carries the change to make, ready to copy into a review.

  4. Say what it did not check.

    The rules it deliberately refuses to run are listed on this page with the reason for each. A linter you cannot audit is a linter you have to trust.

Reference

The rule catalog.

All seventeen rules: three errors, twelve warnings and two notes. An error means Docker rejects the file or silently runs something else; a warning means it builds and it is wrong; a note is worth knowing. Every finding in the playground links to its rule here.

Rule Severity What it catches
DF001 error The first instruction must be FROM
DF002 warning Pin the base image to a tag
DF003 error COPY --from must name an earlier stage
DF004 warning Use COPY for local files, not ADD
DF005 warning Verify a remote ADD
DF006 warning WORKDIR should be absolute
DF007 warning Update and install in the same RUN
DF008 warning Clean the package cache in the same layer
DF009 warning Do not bake secrets into ENV or ARG
DF010 warning Do not run the final stage as root
DF011 warning Use WORKDIR instead of cd
DF012 warning Do not pipe a download into a shell
DF013 warning One CMD and one ENTRYPOINT per stage
DF014 error The exec form must be valid JSON
DF015 warning Drop sudo from build steps
DF016 note MAINTAINER is deprecated
DF017 note Copy the manifest before installing dependencies
DF001 error

The first instruction must be FROM

Only ARG (and comments) may precede FROM. Anything else has no image to act on, and Docker refuses the build. A file with no FROM at all gets the same rule.

Instead of

DOCKERFILE
RUN apt-get update
FROM debian:bookworm-slim

Write

DOCKERFILE
FROM debian:bookworm-slim
RUN apt-get update
DF002 warning

Pin the base image to a tag

An untagged reference means :latest, and :latest moves. Digest pins, scratch, references to an earlier stage and tags that come from an unresolvable build argument are all left alone.

Instead of

DOCKERFILE
FROM node
FROM node:latest

Write

DOCKERFILE
FROM node:22-bookworm-slim
DF003 error

COPY --from must name an earlier stage

A stage can only copy from one defined above it. A numeric index at or past the current stage, and a bare name that matches no stage, both fail the build with "invalid from flag value". Anything carrying a registry, tag or digest is treated as an external image and left alone.

Instead of

DOCKERFILE
FROM alpine:3.20
COPY --from=builder /out /srv

Write

DOCKERFILE
FROM golang:1.23-alpine AS builder
FROM alpine:3.20
COPY --from=builder /out /srv
DF004 warning

Use COPY for local files, not ADD

ADD auto-extracts local tar archives and can fetch URLs, which makes a plain copy behave in ways the line does not say. Extracting an archive is the one job worth keeping ADD for, so an ADD whose sources are all tarballs stays silent.

Instead of

DOCKERFILE
ADD entrypoint.sh /entrypoint.sh

Write

DOCKERFILE
COPY entrypoint.sh /entrypoint.sh
DF005 warning

Verify a remote ADD

A remote ADD bakes whatever the server returns into a layer, and nothing in the build notices when that changes. BuildKit's --checksum= satisfies the rule; so does downloading and verifying in one RUN.

Instead of

DOCKERFILE
ADD https://example.com/tool.tgz /tmp/tool.tgz

Write

DOCKERFILE
RUN curl -fsSL https://example.com/tool.tgz -o /tmp/tool.tgz \
 && echo "<sha256sum>  /tmp/tool.tgz" | sha256sum -c -
DF006 warning

WORKDIR should be absolute

A relative WORKDIR resolves against whatever the previous WORKDIR was, so the directory it selects depends on the lines above it — and changes when they are reordered. Paths starting with a variable are left alone.

Instead of

DOCKERFILE
WORKDIR app

Write

DOCKERFILE
WORKDIR /app
DF007 warning

Update and install in the same RUN

Each RUN is its own layer. A cached update layer plus a rebuilt install layer means installing from a package index that can be months old. Covers apt-get, apt and apk.

Instead of

DOCKERFILE
RUN apt-get update
RUN apt-get install -y curl

Write

DOCKERFILE
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*
DF008 warning

Clean the package cache in the same layer

The package index is committed with the layer, so deleting it in a later RUN reclaims nothing. Satisfied by rm -rf /var/lib/apt/lists/* for apt, --no-cache for apk, and dnf/yum clean all — and suppressed entirely when the RUN carries a --mount=type=cache, because then the cache never enters the image.

Instead of

DOCKERFILE
RUN apt-get update && apt-get install -y curl

Write

DOCKERFILE
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*
DF009 warning

Do not bake secrets into ENV or ARG

Every ENV and ARG value is stored in the image history and readable with docker history by anyone who can pull the image. Overwriting it in a later layer does not remove it. Only names that look like a credential AND carry a non-empty value are flagged.

Instead of

DOCKERFILE
ARG NPM_TOKEN=npm_liveTokenValue
ENV DB_PASSWORD=hunter2

Write

DOCKERFILE
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
DF010 warning

Do not run the final stage as root

Without a USER the process runs as uid 0. Only the FINAL stage is checked — build stages legitimately need root — and only the LAST USER in it counts, because that is the one the container runs as.

Instead of

DOCKERFILE
FROM node:22-bookworm-slim
COPY . /app
CMD ["node", "/app/server.js"]

Write

DOCKERFILE
FROM node:22-bookworm-slim
COPY --chown=node:node . /app
USER node
CMD ["node", "/app/server.js"]
DF011 warning

Use WORKDIR instead of cd

A cd only lasts for the RUN it is written in. The next instruction starts in the stage's WORKDIR again, which is a common cause of "no such file or directory" on the following COPY or CMD.

Instead of

DOCKERFILE
RUN cd /src && make

Write

DOCKERFILE
WORKDIR /src
RUN make
DF012 warning

Do not pipe a download into a shell

curl … | sh executes whatever the server returns, with no signature and no checksum. Detected inside folded RUN lines and heredoc bodies alike, and reported on the line the download is written on.

Instead of

DOCKERFILE
RUN curl -fsSL https://get.example.com/install.sh | sh

Write

DOCKERFILE
RUN curl -fsSL https://get.example.com/install.sh -o /tmp/install.sh \
 && echo "<sha256sum>  /tmp/install.sh" | sha256sum -c - \
 && sh /tmp/install.sh
DF013 warning

One CMD and one ENTRYPOINT per stage

Docker keeps the last one in a stage and silently discards the rest. Counted per stage, so a build stage with its own CMD is fine, and ONBUILD-wrapped instructions do not count.

Instead of

DOCKERFILE
CMD ["node", "server.js"]
CMD ["node", "worker.js"]

Write

DOCKERFILE
CMD ["node", "server.js"]
# the worker runs as its own container, not as a second CMD
DF014 error

The exec form must be valid JSON

Docker only reads the argument as an exec-form array when it parses as JSON — double quotes, no trailing comma. Otherwise it silently runs the whole thing as shell form through /bin/sh -c, brackets included.

Instead of

DOCKERFILE
CMD ['nginx', '-g', 'daemon off;']

Write

DOCKERFILE
CMD ["nginx", "-g", "daemon off;"]
DF015 warning

Drop sudo from build steps

A build step already runs as the stage's USER — root unless you changed it — with no TTY, and most base images do not ship sudo at all. Switch users explicitly instead.

Instead of

DOCKERFILE
RUN sudo apt-get install -y curl

Write

DOCKERFILE
USER root
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*
USER app
DF016 note

MAINTAINER is deprecated

Deprecated in Docker 1.13, in 2017. It still builds, but nothing reads it — the OCI label is what registries and scanners display.

Instead of

DOCKERFILE
MAINTAINER ops@example.com

Write

DOCKERFILE
LABEL org.opencontainers.image.authors="ops@example.com"
DF017 note

Copy the manifest before installing dependencies

A COPY . earlier in the stage than the dependency install means every source-only change invalidates the install layer and reinstalls everything. Recognises npm, yarn, pnpm, pip -r, bundler, composer, go mod download and cargo. Pair the reorder with a .dockerignore so node_modules and .git never enter the context at all.

Instead of

DOCKERFILE
COPY . /app
RUN npm ci

Write

DOCKERFILE
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

The Fence

What it deliberately does not flag.

Every one of these was considered and rejected, and the same list sits in a comment at the top of the engine. Silence you can read is worth more than a rule you learn to ignore.

Version pinning beyond the tag

apt-get install curl with no version and pip install django with no == are both legitimate. DF002 asks for a tag; it never demands a digest or a pinned package set.

EXPOSE

It publishes nothing by itself. A missing one breaks nothing and an extra one costs nothing — pure opinion.

HEALTHCHECK

Ignored outright by Kubernetes, which is where this audience runs images.

pip and npm download caches

Real but small, and the flags move between tool versions. DF008 covers the system package managers, where the megabytes are.

update; install joined with a semicolon

It hides a failed update, but both commands ARE in the same layer, so DF007 has nothing to say. A separate rule about shell operators would be noise.

Tags that come from an unresolvable variable

FROM node:$TAG is decided at build time by --build-arg. Guessing what it resolves to would be a confidently wrong answer.

COPY --from pointing at a registry image

Anything with a slash, colon or @digest is a legal external reference, so DF003 stays quiet.

Deep shell semantics

There is no shell AST here: no set -e reasoning, no exit-code analysis, no variable tracking. The scanner is quote-aware and stops there. It also does not read the argv of an exec-form RUN as a shell string.

Unterminated heredocs, unbalanced quotes and # syntax= dialects

Docker reports these itself, and a partial parse must not invent a rule on top of them.

Anything outside the file

No image is pulled, no registry is queried and no .dockerignore is read, because none of that is in the text you pasted.

Limits, stated rather than hidden: the linter scans up to 200,000 characters, keeps at most 20 findings per rule and 200 in total, and tells you the real count whenever a cap applies.

Next Step

Paste the report into the review.

"Copy report" gives you the whole run as plain text — one line per finding, with the line number, the rule id and the fix. Then keep going: turn the docker run command into a Compose service, or check that the variables the image expects actually exist.

report.txt
Dockerfile Linter — 1 error, 12 warnings, 2 info across 15 lines

ERRORS (1)
  L14 DF014: CMD looks like a JSON array but is not valid JSON.

WARNINGS (12)
  L1 DF002: Base image "ubuntu" has no tag, so Docker resolves it to :latest.
  L6 DF007: apt-get update runs without an install in the same RUN.
  L8 DF012: Piping a download straight into a shell.

FAQ

Questions, answered.

Tap a question to expand the answer.

Seventeen rules, DF001 to DF017, each one a mistake that still builds: an untagged or :latest base image, a COPY --from that names a stage which does not exist yet, ADD where COPY belongs, an unverified remote download, a relative WORKDIR, apt-get update stranded in its own layer, a package cache left inside the image, a secret written into image history by ENV or ARG, a final stage that runs as root, cd instead of WORKDIR, curl piped into a shell, a second CMD that silently wins, a JSON exec form that is not valid JSON, sudo in a build step, MAINTAINER, and a COPY . that defeats the dependency-install cache. Every rule has its own subsection on this page with a before-and-after snippet.

No, and it is not trying to be. hadolint is a binary you install, and it pairs its own rules with everything ShellCheck reports about the shell inside your RUN lines — excellent in CI, and the right answer when you can mandate a toolchain. This is the case hadolint does not cover: a Dockerfile you want checked right now, from a browser, on a machine where you cannot install anything, without sending the file to a server. Seventeen rules instead of a long tail, each with a fix, and an explicit list further down of what it refuses to flag.

No. The parser and all seventeen rules are JavaScript running in your tab — there is no server, no API call and no logging, so 0 bytes are uploaded. That matters more here than for most tools: Dockerfiles carry internal registry hostnames, private package indexes, build arguments and occasionally a credential someone should not have committed.

Because each RUN is a separate layer with a separate cache entry. When you later edit only the install line, Docker reuses the cached update layer — which may be weeks or months old — and runs the install against that stale package index. The result is either a version you did not expect or a 404 for a package that has since been superseded. Joining them into one RUN makes the index and the install share a cache entry, so they can never disagree.

Nothing, until the publisher pushes. :latest is a moving pointer, and an untagged FROM means :latest, so the base image your build used last month and the one it uses tonight can be different major versions with no change on your side — and nothing in the file records which one you actually tested. Pin a tag you have tested. If you need byte-identical rebuilds, add the digest (@sha256:…) as well. This linter asks for a tag and never demands a digest: that trade-off is yours to make.

Because Docker does not reject it — it silently does something else. The exec form is only used when the argument parses as JSON, and JSON requires double quotes. With single quotes the whole thing falls back to shell form, so Docker runs /bin/sh -c "['nginx']" and the brackets and quotes become part of the command. Nothing warns you at build time; the container just fails to start, or starts a shell that is not the process you meant to be PID 1.

Not directly, and it says so rather than guessing. A .dockerignore is a separate file, so a linter that only reads the Dockerfile cannot know whether node_modules, .git and your build output are being shipped into the build context. What it can do is spot the pattern that makes a missing .dockerignore expensive — a COPY . before the dependency install, which is DF017 — and put the .dockerignore advice in that rule's fix text.

Both were considered and deliberately dropped. EXPOSE is documentation: it publishes nothing by itself, so a missing one breaks nothing and an extra one costs nothing. HEALTHCHECK is ignored outright by Kubernetes, which is where most of these images actually run, so flagging it would train this audience to ignore the linter. The full list of what stays silent, with the reason for each, is in the "What it deliberately does not flag" panel above the FAQ.

Up to 200,000 characters — roughly four thousand lines of dense text, which is two orders of magnitude beyond any real Dockerfile. Past that it refuses with a message rather than freezing your tab, because an input that big is a build log or a bundled archive, not a Dockerfile. Findings are capped too, at 20 per rule and 200 in total, and the panel states the cap and the real count whenever it bites.

More free, private DevOps tools.

The Dockerfile Linter is one tool in OpsCanopy — a growing canopy of browser-based validators, converters and testers that never touch a server.

39 free tools, every one offline-capable — opscanopy.com works with no signup and nothing uploaded.

Related: Docker Run to Compose for turning a container command into a service file, Env Example Checker for the variables an image expects, the GitHub Actions Validator and GitLab CI Validator for the pipeline that builds the image, and the JSON ↔ YAML Converter when the config around it needs reshaping — or browse the full tools directory.

Provided as-is for convenience; always confirm critical configuration against the tool that will consume it. OpsCanopy is free and open.