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
Dockerfile Linter playground
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.
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.
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.
-
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.
-
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.
-
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.
-
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 |
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
RUN apt-get update
FROM debian:bookworm-slim Write
FROM debian:bookworm-slim
RUN apt-get update 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
FROM node
FROM node:latest Write
FROM node:22-bookworm-slim 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
FROM alpine:3.20
COPY --from=builder /out /srv Write
FROM golang:1.23-alpine AS builder
FROM alpine:3.20
COPY --from=builder /out /srv 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
ADD entrypoint.sh /entrypoint.sh Write
COPY entrypoint.sh /entrypoint.sh 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
ADD https://example.com/tool.tgz /tmp/tool.tgz Write
RUN curl -fsSL https://example.com/tool.tgz -o /tmp/tool.tgz \
&& echo "<sha256sum> /tmp/tool.tgz" | sha256sum -c - 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
WORKDIR app Write
WORKDIR /app 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
RUN apt-get update
RUN apt-get install -y curl Write
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* 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
RUN apt-get update && apt-get install -y curl Write
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* 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
ARG NPM_TOKEN=npm_liveTokenValue
ENV DB_PASSWORD=hunter2 Write
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci 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
FROM node:22-bookworm-slim
COPY . /app
CMD ["node", "/app/server.js"] Write
FROM node:22-bookworm-slim
COPY --chown=node:node . /app
USER node
CMD ["node", "/app/server.js"] 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
RUN cd /src && make Write
WORKDIR /src
RUN make 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
RUN curl -fsSL https://get.example.com/install.sh | sh Write
RUN curl -fsSL https://get.example.com/install.sh -o /tmp/install.sh \
&& echo "<sha256sum> /tmp/install.sh" | sha256sum -c - \
&& sh /tmp/install.sh 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
CMD ["node", "server.js"]
CMD ["node", "worker.js"] Write
CMD ["node", "server.js"]
# the worker runs as its own container, not as a second CMD 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
CMD ['nginx', '-g', 'daemon off;'] Write
CMD ["nginx", "-g", "daemon off;"] 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
RUN sudo apt-get install -y curl Write
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
USER app 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
MAINTAINER ops@example.com Write
LABEL org.opencontainers.image.authors="ops@example.com" 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
COPY . /app
RUN npm ci Write
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.
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.
What does the Dockerfile Linter check?
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.
Is this hadolint?
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.
Does my Dockerfile ever leave my browser?
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.
Why is apt-get update on its own a problem?
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.
What is wrong with the :latest tag?
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.
Why is CMD ['nginx'] an error rather than a warning?
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.
Can it tell me whether I need a .dockerignore?
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.
Why does it not flag a missing EXPOSE or HEALTHCHECK?
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.
How large a Dockerfile can it handle?
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.