The Generator Wrote It, So It Must Be Fine
You run docker init, mash Enter through five prompts, and four files appear. The Dockerfile looks cleaner than the one you wrote last year. You commit it, push it, and go make coffee.
It is cleaner, too. docker init gives you a solid starting Dockerfile: non-root user, cache mounts, lockfiles bind-mounted instead of copied. But the Python and Node templates fail Docker’s own linter. The Python container takes 10 seconds to stop because the app never sees SIGTERM. And the generated .dockerignore lets .env.production straight into the image. The fixes take about ten minutes.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/docker/docker-init-generated-dockerfile
Wait, Is It Even on Your Server?
docker init is a Docker CLI plugin (docker-init), and Docker’s reference page says “Docker Desktop provides the docker init CLI command.” Plain Docker Engine does not include it. On Engine 29.9.2 alone, docker init is not a command. It prints docker: unknown command: docker init and a hint to run docker --help, then shrugs.
You have two official options. Install Docker Desktop (Linux, Mac, and Windows all work, and Docker’s docs say Desktop for Linux and Engine can sit side by side). Or run docker init on whatever laptop already has Desktop and commit the files. The output is plain text that builds on any Engine with BuildKit, so the generator only needs to live on one machine.
We also tried a shortcut, and it is a one-off test, not an official Docker install path. We downloaded the Docker Desktop for Linux .deb, pulled the docker-init binary out of /usr/lib/docker/cli-plugins/, dropped it into a CLI plugin directory, and it ran fine against plain Engine 29.9.2. It is a statically linked Go binary. One license sentence before you copy that trick at work: per Docker’s docs, Desktop is free for small businesses (fewer than 250 employees and less than $10 million in annual revenue), personal use, education, and non-commercial open source projects, and larger companies need a paid subscription.
All the measurements below used docker-init v1.4.1 (the version Docker Desktop 4.99.0 ships), Engine 29.9.2, and buildx v0.29.1.
What It Asks You
The only flag is --version. Everything else is an arrow-key menu. The templates are ASP.NET Core, Go, Java (Maven, uber jar), Node, PHP with Apache, Python, Rust, and Other. We ran Python, Node, and Go, so everything here covers those three.
Defaults we saw:
| Template | Version default | Run/start default | Port default |
|---|---|---|---|
| Python | 3.14.8 | gunicorn 'app:app' --bind=0.0.0.0:8000 | 8000 |
| Node | 24.18.1 | npm start | none, empty answer refused |
| Go | 1.25 | main package dir ./cmd/server | 8080 |
Node refuses an empty port with “Sorry, your reply was invalid: Value is required”, so we typed 3000.
Look at those version numbers again. In our runs the Python and Node defaults matched the toolchain installed on the machine running docker init: the host had Python 3.14.8 and Node 24.18.1. Go’s default matched the go 1.25 line in go.mod (the host Go was newer). The version baked into your Dockerfile is whatever your laptop happens to have. Read that prompt instead of hammering Enter, especially if your laptop got upgraded last Tuesday and your server did not.
If any of the four files already exist, docker init warns and offers to overwrite all of them. Docker’s docs say “You can’t recover overwritten files.” Back them up first.
What It Gets Right
Credit where it is due. This is the Python Dockerfile, key lines only:
# syntax=docker/dockerfile:1ARG PYTHON_VERSION=3.14.8FROM python:${PYTHON_VERSION}-slim as baseENV PYTHONDONTWRITEBYTECODE=1ENV PYTHONUNBUFFERED=1WORKDIR /appARG UID=10001RUN adduser \ --disabled-password \ --gecos "" \ --home "/nonexistent" \ --shell "/sbin/nologin" \ --no-create-home \ --uid "${UID}" \ appuserRUN --mount=type=cache,target=/root/.cache/pip \ --mount=type=bind,source=requirements.txt,target=requirements.txt \ python -m pip install -r requirements.txtUSER appuserCOPY . .EXPOSE 8000CMD gunicorn 'app:app' --bind=0.0.0.0:8000That is a non-root user with a fixed uid, a pip cache mount that survives rebuilds, requirements.txt bind-mounted so it never becomes an image layer, and PYTHONUNBUFFERED so your logs show up when they happen. Node does the same dance with USER node, an npm cache mount, both package.json and package-lock.json bind-mounted, and npm ci --omit=dev.
Go gets a proper two-stage build with FROM --platform=$BUILDPLATFORM and TARGETARCH for cross-builds, a cache mount on /go/pkg/mod, and a final stage that copies only the binary. If you have read multi-stage Docker builds and BuildKit’s advanced features, you will recognize every trick here. Plenty of hand-written Dockerfiles skip some of them.
One more point in its favor: the app files are root-owned, and the app user cannot write to /app (touch /app/x gives Permission denied). That is a reasonable default. If your app needs to write, mount a volume.
Run the Linter on the Generator
Now audit the output with BuildKit’s built-in checks:
docker build --check .On the generated Python Dockerfile, we got two warnings:
Check complete, 2 warnings have been found!
WARNING: FromAsCasing - https://docs.docker.com/go/dockerfile/rule/from-as-casing/'as' and 'FROM' keywords' casing do not matchDockerfile:10
WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/JSON arguments recommended for CMD to prevent unintended behavior related to OS signalsDockerfile:51(Trimmed: BuildKit also prints a few lines of source context around each hit.)
Node also gets two: LegacyKeyValueFormat (the ENV NODE_ENV production form) and JSONArgsRecommended. Go gets zero. Docker’s own generator fails Docker’s own linter on two of three templates. Funny, but also useful: you now have a free checklist.
The casing and key-value warnings are cosmetic. The JSONArgsRecommended warning is not. It points at a real bug, and on the Python template it costs you 10 seconds every time the container stops.
The 10-Second Stop
The generated Python CMD is shell form. That means Docker runs it as /bin/sh -c gunicorn ..., so the shell is PID 1 and gunicorn runs as a child (PID 7 in our logs). Run docker stop and Docker sends SIGTERM to PID 1. The shell does not pass it on. Docker waits its default 10 second grace period, then sends SIGKILL. Gunicorn never logs a graceful shutdown because it never knew anyone asked.
The fix is exec form, so gunicorn becomes PID 1 and receives the signal itself:
FROM python:${PYTHON_VERSION}-slim as baseFROM python:${PYTHON_VERSION}-slim AS base...CMD gunicorn 'app:app' --bind=0.0.0.0:8000CMD ["gunicorn", "app:app", "--bind=0.0.0.0:8000"]That diff also fixes the casing warning. If PID 1 and signals are new territory, the PID 1 init problem post covers the background, and CMD vs ENTRYPOINT covers why the two forms behave differently.
The Node Trap
Node’s generated CMD npm start is also shell form, yet it stopped in 0.6 seconds. In our run npm start showed up as PID 1, so the shell was not left behind as a parent, and the signal got through. We did not trace exactly how npm handled it. Exit code 1, but it behaved.
You apply the obvious fix for the lint warning: CMD ["node", "server.js"]. And shutdown gets worse. Node as PID 1 with no SIGTERM handler ignores SIGTERM, because the kernel gives PID 1 no default signal actions. Stop time went to 10.2 seconds, exit 137. You fixed the linter and broke the shutdown. Your 2 AM self will thank you for reading this part before the deploy.
tini fixes it. It runs as PID 1, forwards the signal, and reaps zombies:
ENV NODE_ENV productionENV NODE_ENV=productionWORKDIR /usr/src/app
# Node as PID 1 ignores SIGTERM unless the app installs a handler.# tini runs as PID 1, forwards the signal, and reaps zombies.RUN apk add --no-cache tiniENTRYPOINT ["/sbin/tini", "--"]
...CMD npm startCMD ["node", "server.js"]With tini, stop time dropped to 0.3 seconds and exit 143, which means node was killed by SIGTERM, so it actually received the signal. Two alternatives work too: init: true in compose (or docker run --init), or a process.on('SIGTERM', ...) handler in the app itself.
The Measured Results
The sample apps are tiny hello-world apps (Flask, Express, and Go net/http), so treat the sizes as illustrations. The numbers come from the kit’s measure.sh, run twice with the same results (stop times within 0.2 s). The script also plants a .env.production containing DB_PASSWORD=hunter2 and checks whether it ends up in the image.
| Lang | Variant | Check warnings | .env.production in image | docker stop | Exit |
|---|---|---|---|---|---|
| Python | generated | 2 | LEAKED | 10.1 s | 137 |
| Python | fixed | 0 | clean | 0.2 s | 0 |
| Node | generated | 2 | LEAKED | 0.6 s | 1 |
| Node | exec form only | 0 | clean | 10.2 s | 137 |
| Node | fixed (tini) | 0 | clean | 0.3 s | 143 |
| Go | generated | 0 | clean | 0.1 s | 2 |
| Go | fixed | 0 | clean | 0.1 s | 2 |
Image sizes did not change with any fix: 127 MB for Python, 168 MB for Node, 17 MB for Go. These fixes are about correctness, not size.
Exit 137 means SIGKILL. In a deploy that restarts one container, 10 extra seconds is an annoyance. Multiply it by every service you restart and the wait adds up.
The .env.production Leak
Look at the generated .dockerignore. It includes **/.env, **/.git, **/node_modules, **/Dockerfile*, **/compose.y*ml, **/docker-compose*, LICENSE, and README.md, plus language bits (**/__pycache__ and **/.venv for Python, **/.next, **/.cache, **/build, and **/dist for Node). It looks thorough. It blocks the file named exactly .env, and nothing else.
.env.production, .env.local, and every other variant sail through. In our test the planted secret landed at /app/.env.production in the Python image and /usr/src/app/.env.production in the Node image, readable by anyone who can pull it:
docker run --rm --entrypoint cat <image> /app/.env.productionDB_PASSWORD=hunter2Go is clean only by luck: its final stage copies just the binary. The generated README.Docker.md is not ignored either, so it gets copied into the Python and Node images too. Harmless, but pointless.
The fix is two lines appended to the ignore file:
**/.env.*!**/.env.exampleThe second line keeps your example file in the build context in case something reads it. If you want the full tour of ignore-file syntax, the .dockerignore post has it. One kit detail: BuildKit uses <Dockerfile-name>.dockerignore next to the Dockerfile when present, which is how the kit keeps the generated and fixed versions side by side.
Go Is the Good One
The Go template deserves its own paragraph, because it is mostly right. Two stages, CGO_ENABLED=0, a cache mount, ENTRYPOINT [ "/bin/server" ] in exec form so the binary is PID 1 and stops in 0.1 seconds. Zero lint warnings.
The only fix is pinning the final image:
FROM alpine:latest AS finalFROM alpine:3.24 AS finalThe template’s own comment says latest means “whatever happens to be the most recent version of that image when you build,” and suggests a versioned tag or digest. Take its advice. Alpine 3.24 is current at 3.24.4. If you want a smaller final image, Go on scratch goes further.
Compose: Add Restart and a Healthcheck
The generated compose.yaml has one service named server, a build: context: ., a port mapping, and a large commented-out PostgreSQL example (secrets, a healthcheck, and depends_on with condition: service_healthy). It has no restart policy and no healthcheck on your app. Node’s adds NODE_ENV: production, and Go’s adds target: final.
Add the two missing pieces:
services: server: build: context: . ports: - 8000:8000 restart: unless-stopped healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/')"] interval: 30s timeout: 5s retries: 3 start_period: 10sThe python:slim image has no curl or wget, hence the Python one-liner. The Alpine images have busybox wget, so Node and Go use ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] (adjust the port for Go). Limit: measure.sh did not exercise the restart and healthcheck lines. They are config, not measurements. The health checks post covers what to probe.
My Ten-Minute Workflow
- Run
docker initwhere Docker Desktop lives, and read the version prompt. - Commit the untouched output first, so the diff of your fixes is reviewable.
- Run the linter and fix whatever it flags:
docker build --check .- Switch shell-form
CMDto exec form. On Node, add tini in the same commit. - Append
**/.env.*and!**/.env.exampleto.dockerignore. - Pin
alpine:lateston Go. - Add
restartandhealthcheckto the compose file. - Time the stop:
docker compose up -d --buildtime docker compose stopIf that last command takes about 10 seconds, something upstream is still swallowing SIGTERM. It is a ten-second stopwatch test, and it catches the bug that matters most.
Common Questions
Does docker init work without Docker Desktop?
No, not officially. Plain Docker Engine does not include docker init, and running docker init there prints unknown command. Docker Desktop provides the command. Run docker init on a machine with Desktop and commit the files, because the output builds on any Engine with BuildKit.
Can docker init update an existing Dockerfile?
No. Docker init writes fresh starter files from its templates and has no update or merge mode. If any of its four files already exist, it warns and offers to overwrite all of them, and Docker’s docs say you can’t recover overwritten files. Copy your current files somewhere safe before you answer yes.
Which languages does docker init support?
Docker init offers ASP.NET Core, Go, Java (Maven, uber jar), Node, PHP with Apache, Python, Rust, and Other. We tested only Python, Node, and Go, so the lint and signal results above cover those three. Treat the other templates as unaudited and run docker build --check on them.
What files does docker init create?
Docker init creates four files: .dockerignore, Dockerfile, compose.yaml, and README.Docker.md. Docker init has one flag, --version, and asks everything else through interactive prompts with an arrow-key menu. Commit the untouched output before you edit anything, so the review diff shows only your fixes.