Writing Dockerfiles With LLMs in October 2026: Pin the Base Image, Use Multi-Stage Builds, and Scan the Result Before You Ship
Ask a chat model for a Dockerfile and you will get one in seconds. It will usually build. That is the problem: a Dockerfile that builds is not the same as one you want running in production. Generated Dockerfiles tend to use floating base image tags, run as root, copy the whole repository (secrets included) into the image, and ship compilers and package caches nobody needs at runtime.
This guide walks through a practical workflow for using LLMs to write and improve Dockerfiles without inheriting those habits. The short version: give the model real context, pin what you build on, insist on multi-stage builds and a non-root user, and let linters and scanners check the result before a human approves it.
Photo: Shipping container stacks - Port of Rotterdam - 2014 - 2 by AgainErick, CC BY-SA 4.0, via Wikimedia Commons
Why generated Dockerfiles go wrong
Models learned Dockerfiles from years of public examples, tutorials, and quick-start READMEs. Many of those were written to get something running on a laptop, not to be small, reproducible, or safe. So the "average" Dockerfile a model produces tends to reflect tutorial habits:
- Floating tags such as
FROM node:latestorFROM python:3, which means the same Dockerfile builds a different image next month. - Single-stage builds that leave build tools, dev dependencies, and source files in the final image.
- Running as root because no
USERline was added. COPY . .with no.dockerignore, which can pull.envfiles,.githistory, and local credentials into a layer.- Cache-hostile ordering, where source code is copied before dependencies are installed, so every code change reinstalls everything.
- Guessed details: the wrong port, an entrypoint file that does not exist, or a package manager command that does not match your lockfile.
None of these are exotic. They are just easy to miss when a confident-looking file appears in your editor.
Step 1: Give the model the facts it cannot guess
The single biggest improvement comes from the prompt. A model cannot know your runtime version, your lockfile, or how your app starts unless you tell it. Paste the real files instead of describing them:
- The dependency manifest and lockfile name (
package.jsonandpackage-lock.json,pyproject.tomlanduv.lock,go.mod, and so on). - The exact runtime version you run in CI today.
- The start command and the port the app listens on.
- Any native dependencies (for example, an image library that needs system packages at build time).
- Your constraints: non-root, no secrets in layers, target image size, and the platforms you need (for example
linux/amd64andlinux/arm64).
A prompt that works well looks something like this:
Write a production Dockerfile for this Node.js 22 service.
- Use a multi-stage build: install and build in one stage, run in a slim final stage.
- Install with `npm ci` using package-lock.json. Production dependencies only in the final image.
- Run as a non-root user. App listens on port 8080. Start with `node dist/server.js`.
- Order layers so dependency installs are cached when only source code changes.
- No secrets in any layer. Also write a .dockerignore.
- Explain each instruction in one line of comments.
Here are package.json and the current CI config: ...
Asking for a one-line comment per instruction is useful on its own: it forces the model to justify each line, and it makes review much faster.
Step 2: Pin the base image
Floating tags are the most common reproducibility problem. Pin at least to a specific version tag, such as node:22-bookworm-slim rather than node:latest. For production, go further and pin by digest:
FROM node:22-bookworm-slim@sha256:<digest>
A digest identifies one exact image, so the build stays the same until you deliberately change it. Do not let the model write the digest for you. It cannot know the current value and will happily produce a plausible-looking hash that does not exist. Look it up yourself, for example with docker buildx imagetools inspect node:22-bookworm-slim, and let a dependency bot such as Renovate or Dependabot open pull requests when a newer digest is published. That way you still get security updates, but on purpose and with a review.
Step 3: Insist on a multi-stage build
Multi-stage builds are where most size and security wins come from. The build stage can carry compilers, dev dependencies, and source code. The final stage copies in only what is needed to run.
Here is the shape you should expect back for a Node.js service. Treat it as a pattern to compare against, not something to paste blindly:
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /app
# Copy only manifests first so this layer is cached until dependencies change
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
# Drop dev dependencies before copying node_modules to the final stage
RUN npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
# The official Node images include a non-root "node" user
USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]
Things to check in whatever the model gives you:
- The final stage contains no source tree, test files, or build tools unless the app genuinely needs them.
- Dependency manifests are copied and installed before the rest of the source, so code edits do not bust the install cache.
CMDuses the exec form (a JSON array). The shell form wraps your process in/bin/sh -c, which can stop signals such as SIGTERM from reaching the app and slow down clean shutdowns.- On Debian or Ubuntu bases,
apt-get installuses--no-install-recommendsand the sameRUNline removes/var/lib/apt/lists/*.
For compiled languages such as Go or Rust, ask whether a distroless or scratch final image fits. It often does, but the model may forget that you then also need CA certificates and timezone data if the app makes TLS calls or parses local times.
Step 4: Keep secrets out of every layer
Anything written in a layer stays in the image history, even if a later instruction deletes it. That makes three generated patterns worth rejecting on sight:
ARGorENVlines carrying tokens or passwords. Build args can show up in image metadata and history.COPY .npmrcor similar credential files followed byRUN rmin a later step.COPY . .without a.dockerignore.
The fix for build-time credentials is a BuildKit secret mount, which exposes the file only for that one RUN step:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
Build it with docker build --secret id=npmrc,src=$HOME/.npmrc .. Runtime secrets belong in your orchestrator's secret store, not in the image at all. And always ask for a .dockerignore alongside the Dockerfile. A reasonable starting point excludes .git, .env*, node_modules, build output, local logs, and editor folders.
To double-check, run docker history --no-trunc your-image and look for anything that resembles a credential.
Step 5: Let tools review it before a human does
An LLM is a fast first drafter, but it is a poor final judge of its own output. Put cheap automated checks in front of human review:
- Lint the Dockerfile. Hadolint catches common issues such as unpinned packages, missing
--no-install-recommends, and shell-form commands. Docker's own build checks (docker build --check .) flag several mistakes as well. - Build it in CI for every platform you ship, not just your laptop's architecture.
- Scan the image. Tools such as Trivy (
trivy image your-image) or Docker Scout (docker scout cves your-image) list known vulnerabilities in the OS packages and language dependencies you shipped. - Run a smoke test. Start the container, hit a health endpoint, and confirm the process is not running as root:
docker run --rm your-image idshould not printuid=0. - Check the size. Compare the image size against the previous version. A sudden jump usually means a build tool or cache slipped into the final stage. A layer explorer such as
diveshows exactly which instruction added what.
When a check fails, paste the exact output back to the model and ask for a targeted fix. Linter and scanner messages are precise, and models are good at acting on precise feedback. That loop is far more reliable than asking a model to "make this Dockerfile more secure" in the abstract.
Step 6: Use the model to review existing Dockerfiles too
The same workflow works in reverse. Paste an older Dockerfile and ask the model to list problems, ranked by risk, with a one-line reason for each. Then fix them one at a time with the checks above. This is a low-risk way to modernize a pile of legacy images, because each change is small, explained, and verified by a build and a scan.
Ask it to flag things it is unsure about instead of guessing. For example, "If you cannot tell which port the app uses, say so" is better than a silently invented EXPOSE 3000.
A quick review checklist
- Base image pinned to a version tag, ideally a digest you looked up yourself.
- Multi-stage build with a slim final stage.
- Dependencies installed from the lockfile, before source is copied.
- Non-root
USERin the final stage. - Exec-form
CMDorENTRYPOINT. - No secrets in
ARG,ENV, or any copied file; BuildKit secret mounts for build-time credentials. - A
.dockerignorethat excludes.gitand.envfiles. - Lint, build, scan, and smoke test pass in CI.
The bottom line
LLMs are genuinely useful for Dockerfiles: they remember syntax you do not, suggest patterns like cache mounts, and turn a vague "containerize this" into a working starting point. Just treat the output as a draft. Feed the model your real manifests and constraints, pin your base image yourself, demand multi-stage builds and a non-root user, keep secrets out of layers, and let linters, scanners, and a smoke test have the final say before anything reaches production.
Comments
Post a Comment