Skip to content
Latchkey LogoLatchkey home

Docker dockerfile parse error in CI

A docker dockerfile parse error is BuildKit refusing your file before any step runs, and the line number in it is reliable because only one of the two parsing passes produces that prefix. The pass that runs first fails with no line prefix at all, which is why two different broken Dockerfiles give you two very differently shaped messages.

Diagram of the three passes over a Dockerfile and which one prefixes its error with a line
Only the instruction pass wraps its error as "dockerfile parse error on line 2: unknown instruction: RUNN (did you mean RUN?)". The lexer before it and the word expander after it both fail without that prefix.

What this error means

The build ends in under a second. Nothing is pulled, no step index appears, and the output is a caret diagram pointing at one line followed by a single ERROR line. Which shape you get depends on where in the frontend your file was rejected, and only one of the three shapes carries "dockerfile parse error on line N". The block below was captured on this machine, not on a runner.

Captured locally on Docker 29.6.2 and buildx v0.35.0-desktop.2, 2026-09-21
--- an instruction the parser does not recognize
ERROR: failed to build: failed to solve: dockerfile parse error on line 2: unknown instruction: RUNN (did you mean RUN?)
--- a quote that never closes, which fails later and without the line prefix
unexpected end of statement while looking for matching double-quote

Three passes read your Dockerfile, and only one of them says "parse error"

BuildKit does not have a single Dockerfile parser. A lexer turns the bytes into a tree of nodes, an instruction pass turns each node into a command, and much later a word expander resolves variables and quoting inside the arguments those commands carry. All three can refuse your file, and they word it differently.

The "dockerfile parse error on line N" prefix belongs to exactly one of them. It comes from an unexported error type in the instructions package whose formatting is one line: the words, the start line of the node that failed, and the inner error. Nothing in the lexer or the expander goes through that type, so a file that dies in either of them gives you a message with no line prefix, and searching for "dockerfile parse error" to understand it finds nothing that applies.

PassExample of what it refusesShape of the message
Lexer, BuildKit frontend/dockerfile/parserAn unterminated heredoc, a file with no instructions, a line over the scanner limitThe bare sentence, with a location attached separately for the caret diagram
Instruction pass, BuildKit frontend/dockerfile/instructionsAn unknown instruction, an unknown flag on a known instruction"dockerfile parse error on line N: " and then the inner error
Word expander, BuildKit frontend/dockerfile/shellA quote or a brace that never closes inside an argument"unexpected end of statement while looking for matching " and the character

Common causes

An instruction or a flag the instruction pass does not know

The commonest one, and the only family that reliably carries the line number. A misspelled instruction (RUNN, FORM, COPPY) and a misspelled flag on a real instruction (COPY --form=build) both fail here, and both get a suggestion when the typo is under three edits from something valid. In our experience a rename that left one caller behind is more common in CI than a fresh typo, because the fresh typo fails on the author's laptop first.

A quote or brace that never closes inside an argument

This is the expander, not the parser, and it is why your search for "dockerfile parse error" came back empty. The sentence is "unexpected end of statement while looking for matching" followed by the character it wanted, and there are three separate return sites for it: a generic stop character, a single quote and a double quote. It fires while resolving the words of an instruction that parsed fine.

A heredoc with no terminator, or a `# syntax=` line that is not first

An unterminated heredoc is refused by the lexer with "unterminated heredoc" and no line prefix. A syntax directive that is not the first line is not refused at all: it is treated as a comment, your build silently uses the built in frontend, and the instruction that needed the newer frontend fails as unknown. Those two failures look nothing alike and have the same root, which is a line in the wrong place.

CRLF line endings from a Windows checkout

A carriage return at the end of a continued line becomes part of the token. The failure it produces depends on which pass trips over it first, so this cause can present as any of the three shapes above. It is worth ruling out early in CI specifically, because a repository without a .gitattributes entry can check out differently on a Windows runner than it does anywhere else.

How to fix it

Read the caret diagram, not just the ERROR line

  1. Run the build with plain progress so the diagram is not collapsed by the fancy renderer.
  2. The >>> row is the line BuildKit was on. For the instruction pass that is the failing line; for the expander it is the instruction whose argument would not resolve.
  3. If the ERROR line has no "on line N" prefix, stop looking for a syntax error in the instruction and look at quoting inside its arguments.
Terminal
docker build --progress=plain -f Dockerfile .

Pin the frontend before you compare two machines

Put a pinned # syntax= line on the first line of the file so CI and your laptop parse with the same code. This is also the cheapest way to find out whether your problem is a frontend version at all: pin it to the version your laptop has and see whether CI still refuses the file.

Dockerfile
# syntax=docker/dockerfile:1.19
FROM golang:1.25 AS build

Force LF for Dockerfiles in the repository, not on the runner

A sed in the workflow fixes one build and hides the cause from the next person. A .gitattributes entry fixes every checkout on every runner and every laptop, and it is the only version of this fix that survives someone adding a second workflow.

.gitattributes
# .gitattributes
Dockerfile text eol=lf
Dockerfile.* text eol=lf
*.dockerfile text eol=lf

Lint in a step that cannot be skipped, and know what the built in check does not do

  1. Run a linter such as hadolint on Dockerfiles in a job that does not depend on the build succeeding.
  2. Use docker buildx build --check for the checks BuildKit itself ships, which are style and correctness warnings on the parsed file.
  3. Do not expect --check to catch a missing COPY source. The documentation says the check method evaluates build checks without executing the build, and those checks are lint rules over the parsed file, so a path that is not in the context is not something it goes looking for.
.github/workflows/ci.yml
- name: Lint Dockerfiles
  run: docker run --rm -i hadolint/hadolint < Dockerfile

- name: BuildKit checks
  run: docker buildx build --check .

The "did you mean" tail is a distance test, so its absence means nothing

An unknown instruction and an unknown mount key are both passed through the same suggestion helper before they are returned. It runs a Levenshtein distance against the valid values and appends " (did you mean X?)" only when some candidate is closer than 3 edits. RUNN against RUN is one edit, so the hint appears. A wholly wrong word is not close to anything, so it does not.

That matters because people read the missing hint as "BuildKit has no idea what this is" and start looking for a frontend version problem. It only means your typo was a big one. Compare what we captured on this machine: RUNN got a suggestion, and the same helper declined to suggest anything for a stage name four edits away on the target-stage page linked below.

Dockerfile
# both of these are "dockerfile parse error on line 2: unknown instruction: ..."
# only the first gets a suggestion, because RUNN is one edit from RUN
RUNN echo hello
EXECUTE echo hello

A syntax directive moves the parse somewhere else entirely

A # syntax= line on the first line of the file tells BuildKit to fetch that image and hand it the Dockerfile. From that point the parse you are debugging is the one inside the frontend image, not the one inside your local BuildKit. An instruction your Docker version does not know can still be valid, and an instruction it does know can be rejected, depending on which tag you named.

So when you compare a failing CI build against a passing laptop build, check this line before you compare Docker versions. docker/dockerfile:1 floats and will not be the same image next month; a pinned tag is what makes the parse reproducible. The directive also has to be first: put it after a comment block or a blank line and it is read as an ordinary comment, silently, with no error at all.

Dockerfile
# syntax=docker/dockerfile:1.19
FROM busybox
RUN --mount=type=cache,target=/root/.cache echo hi

Why no recorded run backs this page

A parse failure never reaches a runner in any interesting sense. It is decided in the first few milliseconds, from the bytes of one file, by code that has not opened a network connection or started a container. Running it on a Latchkey runner would record the same sentence this machine records, and presenting that as runner evidence would imply the runner had something to do with it.

What is checkable here is checked instead: the error type that formats the line prefix, the three return sites that do not go through it, and the distance cutoff in the suggestion helper. The captured block above came from a local build so you can see the two shapes side by side, and it says so in its label rather than borrowing the authority of an Actions log.

How to prevent it

  • Put a pinned # syntax= directive on the first line of every Dockerfile you build in CI.
  • Keep a .gitattributes rule forcing LF on Dockerfiles so a Windows checkout cannot change the bytes.
  • Run the linter in a job that does not wait on the image build, so a parse failure is reported once and early.
  • When you rename an instruction or a flag across many Dockerfiles, grep for the old spelling rather than trusting the build to find them all.

Frequently asked questions

Why does my error have no line number?
Because the line prefix is added by one specific error type in the instruction pass, and your file failed somewhere else. The lexer and the word expander both return their sentences without it. If your message begins "unexpected end of statement" you are looking at the expander, and the problem is quoting inside an argument rather than the instruction itself.
What does "did you mean RUN" mean in a Docker build error?
It is a Levenshtein suggestion appended by a shared helper in BuildKit. It only appears when a valid value is fewer than three edits from what you wrote, so RUNN gets it and a completely different word does not. The absence of a suggestion is not a signal about your Docker version.
Does the syntax directive change which errors I can get?
Yes, and that is the point of it. The directive names a frontend image, and from then on that image parses your Dockerfile. Instructions, flags and error wording all come from it rather than from your installed Docker. A build that parses on your laptop and not in CI is worth checking here before anywhere else.
Can docker buildx build --check find a broken Dockerfile before I build?
It finds what BuildKit calls checks, which are lint style warnings on the parsed file, and it will surface a parse failure because it has to parse. The documentation is explicit that the check method evaluates build checks without executing the build, so a missing COPY source, which is only discovered when the build is solved, is outside what it looks at.

Related guides

References

A one-line fix should not cost a full cold build. Latchkey keeps the layer cache on the runner. Start free → 30-day trial · No credit card