Self-hosted service
reteps/dockerfmt avatar
reteps/dockerfmt

dockerfmt: a Dockerfile formatter that parses like BuildKit and formats RUN steps with shfmt

Dockerfile formatter. a modern dockfmt.

625 stars23 forksGoMIT

At a glance

What is it?
dockerfmt is an opinionated Dockerfile formatter written in Go, built on the BuildKit parser so it sees the same syntax the daemon does, and it runs shfmt over the shell inside RUN steps. It converts legacy ENV syntax, upgrades MAINTAINER to LABEL, supports heredocs, honours EditorConfig, and ships a pre-commit hook, a Node.js WebAssembly binding and a CI image.
Who is it for?
Adopt dockerfmt if your team has more than one Dockerfile and wants the RUN steps in them to look like the same shell code, because delegating to shfmt and parsing with BuildKit gives a more accurate result than a hand-written formatter can. Do not adopt it if your Dockerfiles rely on the escape parser directive, which is not supported, and do not run it in write mode inside CI, since a pipeline that rewrites files and then passes is not a check.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 118 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Why a second Dockerfile formatter, and what makes this one different

dockerfmt is described as an opinionated Dockerfile formatter, and as the spiritual successor to dockfmt. The interesting part is the last line of that description: it is built on the BuildKit parser, the same parser the Docker daemon itself uses.

That choice does most of the work in the project. A formatter that hand-parses Dockerfile syntax is guessing at the grammar, and it guesses wrong on the constructs that were added after the original format settled, heredocs being the obvious one. Building on the daemon's own parser means the tool sees the file the way the builder will, so a Dockerfile that parses is a Dockerfile that formats rather than one that gets mangled.

The other half of the design is the delegation to shfmt for the shell inside RUN steps. Rather than implementing a shell formatter, dockerfmt hands the extracted command to the existing mvdan.cc/sh formatter, which is the same tool the shell community already uses. The result is that a RUN step gets a real shell formatting pass, with consistent && chains, indentation and quoting, rather than a Docker-aware approximation.

The feature list spells out what that produces. Shell commands in RUN steps are formatted via shfmt. Inline comments are preserved and re-aligned, even across multiline RUN steps. Whitespace and extra blank lines are normalised across all directives. Legacy ENV syntax is converted from ENV key value to ENV key=value. Deprecated directives are upgraded, with MAINTAINER becoming LABEL. Heredocs in RUN steps are supported. Input comes from files or stdin, there is a pre-commit hook, JS and WASM bindings for Node.js, and a Docker image for CI use.

The primary language is Go, the module requires Go 1.24.1, the licence is MIT, the last push was on 2026-06-27 and the recent releases are v0.5.4 and v0.5.3 from that same day, and v0.5.2 from 2026-04-06. The project is not archived, and the version being below 1.0 is a fair signal about how settled the formatting rules are.

The before-and-after, which is the best argument for the tool

The README's demo is a single Dockerfile shown twice, and the second version is the interesting half because it shows three separate kinds of change at once.

The input looks like this:

dockerfile
MAINTAINER me

FROM node:lts-alpine          as       builder



COPY        .      /app
WORKDIR         /app
ENV MY_VAR my-value
ENV a=1 \
  b=2 \
            c=3
RUN apt-get update && \
    # install deps
    apt-get install -y --no-install-recommends \
        vim curl git && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* \
    && find /tmp -not -path /tmp -delete

and the output is:

dockerfile
LABEL org.opencontainers.image.authors="me"

FROM node:lts-alpine as builder

COPY . /app
WORKDIR /app
ENV MY_VAR=my-value
ENV a=1 \
    b=2 \
    c=3
RUN apt-get update \
    # install deps
    && apt-get install -y --no-install-recommends \
        vim curl git \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* \
    && find /tmp -not -path /tmp -delete

Three things happened. The MAINTAINER directive became a LABEL, and specifically the org.opencontainers.image.authors label rather than an arbitrary string, so the upgrade is a rewrite with a convention rather than a rename. The legacy ENV MY_VAR my-value became ENV MY_VAR=my-value. And the three-line ENV with the odd continuation indentation became a consistent four-space continuation.

Then there is the RUN step, and this is where shfmt earns its place. The input had apt-get update && on its own line with the comment install deps trailing it at a strange indent, and a chain that mixed a leading && style in one place with a trailing && style in another. The output puts the comment on its own line, moves the && to the start of the following lines consistently, indents the package list under the command, and gives the find invocation its own line.

None of that is Docker-specific. It is exactly what shfmt does to a shell script, and the value of delegating is that you get shfmt's behaviour rather than a Docker-flavoured approximation of it. The same is true of the comment handling: the inline comment inside the RUN chain is preserved rather than dropped, which is the failure mode of a formatter that treats a RUN body as an opaque string.

The demo also shows the normalisation of blank lines, since the input had three of them between FROM and COPY and the output has one.

Installing it, and the four invocations you need

There are three installation routes, and the first is a binary from the releases page.

If you have Go, the module installs directly:

bash
go install github.com/reteps/dockerfmt@latest

If you do not want Go on the machine at all, the container image does the job:

bash
docker run --rm -v $(pwd):/pwd ghcr.io/reteps/dockerfmt:latest /pwd/Dockerfile

The usage surface is four commands, and they map to four different situations:

bash
# format and print to stdout
dockerfmt Dockerfile

# format in place
dockerfmt -w Dockerfile

# read from stdin
cat Dockerfile | dockerfmt

# check if already formatted (exits non-zero if not)
dockerfmt -c Dockerfile

Formatting to stdout is what you want the first time, because it shows you what the tool would do before it touches anything. In-place writing with -w is what you want once you trust it. Reading from stdin is the composable one, which is how you would use it from a script that generates Dockerfiles. And -c is the check mode, which exits non-zero if the file is not already formatted, and that is the one you wire into CI.

The flag list adds a few options beyond those four. There is an indent flag for the number of spaces, defaulting to 4. There is a newline flag to end the file with a trailing newline. There is a space-redirects flag so that redirect operators are followed by a space, which is a style choice some shell formatters make and some do not. And there are the standard help and version commands, plus a completion subcommand that generates an autocompletion script for the shell.

The tool is built with cobra, which is why the interface reads that way, and the repository layout reflects a normal Go project with a cmd/ directory, a lib/ directory for the formatting logic, a main.go, a js/ directory for the Node.js bindings, and a dockerfmt_test.go alongside a tests/ directory.

EditorConfig, and why the flags always win

dockerfmt reads EditorConfig files to pick up project-level formatting defaults, and the mapping table is short because the supported set is deliberately narrow.

indent_size maps to the indent flag, and insert_final_newline maps to the newline flag, and both are standard EditorConfig keys. space_redirects maps to the space-redirects flag and is marked as a custom, non-standard key, which is the honest way to describe an EditorConfig extension that only this tool understands.

The precedence rule is simple and stated plainly: CLI flags always take precedence over EditorConfig values. That ordering is the right one, since an explicit flag is a decision made at the moment of the run while an EditorConfig file is a project-wide default, and in a repository with both, the person at the keyboard should win.

An example .editorconfig section for Dockerfiles sets indent_size to 2 and insert_final_newline to true.

There is one limitation that catches people out, and the README calls it out separately. EditorConfig is only applied when formatting files by path. It is not used when reading from stdin, since there is no file path to resolve against. So the same tool invoked as dockerfmt Dockerfile and as cat Dockerfile | dockerfmt can produce different indentation if your project uses EditorConfig, and that is a real behavioural difference rather than a documentation gap.

The ignore comment, and the three cases it exists for

To skip formatting for a specific directive, you place a dockerfmt-ignore comment on the line immediately before it:

dockerfile
# dockerfmt-ignore
RUN   echo   "this stays exactly as-is"
RUN echo "this gets formatted normally"

The first RUN keeps its original spacing and the second is formatted normally, and the rule is that the ignore comment applies only to the next directive.

The README is explicit about why this escape hatch exists, and the examples are the useful part:

dockerfile
# dockerfmt-ignore
RUN { echo hello && echo world; }

# dockerfmt-ignore
RUN echo hello; echo world

Both of those are cases where a shell formatter would either change the meaning of the command or produce output the author does not want. Command grouping with braces and unescaped semicolons are the two constructs the RUN formatter cannot handle, and the Limitations section says the same thing from the other side: the RUN formatter does not support command grouping or unescaped semicolons, and these are returned unformatted.

So the ignore comment is not a convenience for people who disagree with the formatter's opinions. It is the escape hatch for the specific inputs the formatter cannot correctly process, and the examples in the README are exactly the two constructs that trigger it.

There is a third limitation worth noting, because it is not covered by the ignore comment. The # escape=X parser directive is not supported. That is a Dockerfile feature that changes how the backslash and quote characters are parsed, and because dockerfmt does not implement it, a Dockerfile using it will not parse the way the author intended, and there is no ignore comment that fixes that, because the problem is in the parse rather than in the formatting of one directive.

The pre-commit hook, and what CI should run

The pre-commit configuration is a short YAML block added to your .pre-commit-config.yaml:

yaml
repos:
  - repo: https://github.com/reteps/dockerfmt
    rev: main  # run `pre-commit autoupdate` to pin a version
    hooks:
      - id: dockerfmt
        args:
          - --indent=4
          - --newline
          - --write

The hook id is dockerfmt, the repository is the GitHub project, and the args are the command line flags, here indent 4, newline and write. The comment on the rev line is a practical note: run pre-commit autoupdate to pin a version, because pointing at main means every commit uses whatever the branch currently holds, which is a bad default for a formatter that rewrites files.

For CI, the check mode is the right call rather than the write mode, because a CI job that rewrites files and then passes is not a check. The dockerfmt -c invocation exits non-zero when a file is not already formatted, which is exactly the behaviour a pipeline wants: fail the build and let a developer run the formatter locally, or let a bot commit the result on a separate branch.

The container image exists for that reason. A CI job can run the published image against a checkout without installing Go, which is a small but real convenience for teams whose pipelines are otherwise language-agnostic.

The Node.js binding is the other integration point. The repository has a js/ directory with its own README, and the feature list calls it JS and WASM bindings for Node.js. A WebAssembly build of a formatter is unusual enough to be worth knowing about, because it means the formatting logic can run inside an editor extension or a toolchain written in JavaScript without a Go toolchain and without a subprocess, which is how formatting gets into editors in the first place.

The remaining limitations are worth stating for a team that wants to adopt it wholesale. There is no line wrapping for long JSON-form commands, so a RUN step with a very long inline JSON payload stays long.

How to evaluate it for your repository

The evaluation is quick, because the tool is small and the demo shows exactly what it does.

The first question is whether your Dockerfiles are already close to the output style. The tool is opinionated, and the word appears in the README's first line, so if your house style is two-space indentation and no trailing newline, you can get that with flags or EditorConfig, but you cannot get a different overall shape. Formatting a file the tool dislikes produces a large diff the first time, which is a one-off cost that is worth budgeting for rather than discovering in a review.

The second is whether your Dockerfiles use the constructs the tool cannot handle. If your RUN steps contain command grouping or unescaped semicolons, you need the ignore comments, and the README's examples show exactly how to place them. If you use the escape parser directive, that is a blocker, since it is not supported and cannot be worked around with an ignore comment.

The third is the build and deployment path. If your team already has Go, go install is one command. If not, the container image avoids installing a toolchain, and the pre-commit hook means the formatting happens before the code is committed rather than in a pipeline. The JS and WASM bindings are worth looking at if you want formatting inside an editor or a JavaScript-based toolchain.

The fourth is the risk of a formatter that rewrites files. The mitigation is the check mode in CI plus the write mode locally, and the fact that the tool reads and writes the same file it parses means the check is accurate rather than approximate.

One last note on the project's stage. The version is below 1.0 and two releases landed on the same day in June 2026, which suggests active development with changing formatting rules. If you adopt it, pin the version, and if you adopt the pre-commit hook, pin the rev as the example comment recommends.

Editorial conclusion

Adopt dockerfmt if your team has more than one Dockerfile and wants the RUN steps in them to look like the same shell code, because delegating to shfmt and parsing with BuildKit gives a more accurate result than a hand-written formatter can. Do not adopt it if your Dockerfiles rely on the escape parser directive, which is not supported, and do not run it in write mode inside CI, since a pipeline that rewrites files and then passes is not a check. Before you commit, run it on one file and read the diff rather than trusting the demo, add the pre-commit hook with a pinned rev, and use the check mode in CI so that a Dockerfile that drifts out of format fails the build instead of being silently rewritten.

Frequently asked questions

What is dockerfmt?

It is an opinionated Dockerfile formatter written in Go, described as a spiritual successor to dockfmt. It formats directives, normalises whitespace, and uses shfmt to format the shell commands inside RUN steps, and it is built on the BuildKit parser.

How do I install and run dockerfmt?

Download a binary from the releases page, run go install github.com/reteps/dockerfmt@latest, or use the container image with docker run. To use it, run dockerfmt Dockerfile to print the result, dockerfmt -w Dockerfile to write in place, pipe a file in with cat Dockerfile | dockerfmt, and use dockerfmt -c to check formatting and exit non-zero when a file is not formatted.

Does dockerfmt support EditorConfig?

Yes. It reads indent_size, insert_final_newline and a custom space_redirects key, and CLI flags always take precedence. EditorConfig is only applied when formatting files by path and is not used when reading from stdin, since there is no file path to resolve against.

How do I stop dockerfmt from reformatting a directive?

Place a # dockerfmt-ignore comment on the line immediately before the directive. The comment applies only to the next directive, and the README gives it as the escape hatch for cases such as command grouping and unescaped semicolons, which the RUN formatter cannot handle.

What are the limitations of dockerfmt?

The RUN formatter does not support command grouping or unescaped semicolons and returns them unformatted, the # escape=X parser directive is not supported, and there is no line wrapping for long JSON-form commands. The project also publishes JS and WASM bindings for Node.js and a Docker image for CI use.

Official sources

  1. Issues
  2. License: MIT
  3. README
  4. Releases
  5. reteps/dockerfmt on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/reteps-dockerfmt.svg)](https://hysenlabs.com/projects/reteps-dockerfmt)

Was this useful?