TL;DR: Asking AI to write a Dockerfile gets you something that builds in seconds. It rarely gets you something safe to ship: root user by default, an unpinned :latest base image, secrets baked into ARG or ENV, and no .dockerignore. This post gives the prompt that asks for better, the multi-stage pattern that should come back, and the checklist to read the output before you trust it.
A model that has seen a few million Dockerfiles can produce one that builds on the first try. That is a low bar. docker build succeeding tells you the syntax is valid; it says nothing about whether the resulting image runs as root, ships a copy of your .env file, or trusts a base image tag that could point to something different tomorrow. Every fact and directive below is checked against Docker's own documentation, dated 3 September 2026, including one specific, obsolete piece of syntax a model trained on older examples will still hand you.
What should you actually ask for in a Dockerfile prompt?
A vague request ("write me a Dockerfile for this Node app") gets you a vague, permissive answer. Naming the constraints up front does most of the work a review would otherwise have to do after the fact.
Write a Dockerfile for this [language/runtime] app.
Requirements:
- Multi-stage build: a build stage that installs dependencies and
compiles/builds, and a slim runtime stage that copies only the
build output.
- Pin the base image to a specific version tag, not "latest".
- Run the app as a non-root user in the final stage.
- Do not use ARG or ENV to pass secrets, API keys, or credentials.
If the build needs a secret, use a build secret mount instead.
- Include a .dockerignore that excludes .git, .env, node_modules
(or the language equivalent), and any local secret files.
- Add a HEALTHCHECK appropriate for this app.
Explain each stage in a comment above it.
<paste your package.json / requirements.txt / go.mod / project structure>
The comments matter more than they look: an explanation forces the model to justify each layer, which is exactly where a bad default (running as root because it's simpler) tends to surface in its own reasoning.
What does a correct multi-stage Dockerfile actually look like?
Docker's own guidance is direct about the mechanism: with multi-stage builds, "You can selectively copy artifacts from one stage to another, leaving behind everything you don't want in the final image." A build stage can carry a full compiler toolchain and dev dependencies; the runtime stage that ships only needs the output.
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
RUN groupadd --system app && useradd --system --gid app --home /app app
WORKDIR /app
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/node_modules ./node_modules
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD node dist/healthcheck.js || exit 1
CMD ["node", "dist/server.js"]
Three things in that final stage are doing the security work, and each maps to a real Dockerfile instruction, not a convention. USER app matters because, per Docker's reference, "The USER instruction sets the user name (or UID) and optionally the user group (or GID) to use as the default user and group for the remainder of the current stage." Every RUN after it, and the container's ENTRYPOINT/CMD at runtime, drop out of root. COPY --from=build pulls only the compiled output and installed dependencies across the stage boundary, so the compiler, the source .git history, and any build-only tooling never reach the shipped image. And node:22-slim is a version-pinned tag rather than a bare node or node:latest, which is the cheap half of the next fix.
The size payoff of that pattern is the same mechanism doing double duty: nothing you don't COPY --from=build exists in the final image, so a compiler toolchain, dev-only dependencies, and the intermediate build cache never ship at all. If your prompt doesn't specify the runtime base image, a model will often default to the same full image for both stages, which builds fine and ships noticeably heavier than it needs to. Ask explicitly for a slim or minimal variant on the final stage (-slim, -alpine, or a distroless base, depending on the runtime), and say so as a separate instruction from "use multi-stage" — a model can technically satisfy "multi-stage" while still choosing a full image for the runtime stage. Two caveats worth naming in the same prompt: Alpine's musl C library occasionally breaks native dependencies built against glibc, and distroless images ship no shell at all, which makes docker exec-based debugging impossible. Neither is a reason to avoid them by default, but both are reasons to actually build and run the result before committing to one, which is the next question.
Five things AI-generated Dockerfiles get wrong
These are the defaults a model reaches for when you don't rule them out, and each one is a real, documented Docker mechanism you can point at to fix it.
| Problem | What a model tends to write | The fix |
|---|---|---|
| Runs as root | No USER line at all | Add USER <name> in the final stage, after creating the user |
| Unpinned base image | FROM node or FROM node:latest | Pin a tag (node:22-slim), or a digest for full reproducibility |
| Secrets baked into the image | ARG API_KEY or ENV API_KEY=... | RUN --mount=type=secret,id=api_key at build time |
| Whole directory copied in | COPY . . with no .dockerignore | A .dockerignore excluding .git, .env, and local secrets |
| No health signal | No HEALTHCHECK instruction | Add one appropriate to the app, even a minimal HTTP check |
Root by default. Docker's own build guidance is unambiguous: "If a service can run without privileges, use USER to change to a non-root user." A model asked for "a working Dockerfile" has no reason to add that line unless you ask, because omitting it never breaks the build.
:latest, or no pin at all. Docker's documentation explains why this is a supply-chain issue, not a style nitpick: "Image tags are mutable, meaning a publisher can update a tag to point to a new image", so a build today and the same build next month are not guaranteed to use the same base. Pinning to a digest, FROM alpine:3.21@sha256:..., removes that variable entirely.
Secrets in ARG or ENV. This is the most consequential mistake and the one models make most confidently, because it "just works" in a demo. Docker's build-secrets documentation states it plainly: "Build arguments and environment variables are inappropriate for passing secrets to your build, because they persist in the final image. Instead, you should use secret mounts or SSH mounts, which expose secrets to your builds securely." The correct pattern:
# syntax=docker/dockerfile:1
FROM python:3.13-slim
RUN --mount=type=secret,id=pip_index_token \
PIP_INDEX_URL=https://$(cat /run/secrets/pip_index_token)@pypi.example.com/simple \
pip install mypackage
built with docker build --secret id=pip_index_token,src=./token.txt . — the token is available only inside that one RUN step and is never written into an image layer.
COPY . . with no .dockerignore. Copying the whole build context is the fastest way to ship a .git folder, a .env file, or a stray credentials JSON straight into an image layer, where it survives even if a later RUN rm deletes it from the final filesystem view — Docker's context documentation confirms the mechanism runs the other way: a .dockerignore file "causes the following build behavior" by removing matched paths from the context before it ever reaches the builder, so excluded files are never available to COPY in the first place. Ask explicitly for one, or write it yourself:
.git
.env
.env.*
node_modules
*.log
Dockerfile
.dockerignore
You do not have to take a model's word for any of this. Docker ships a linter for exactly the second and third mistakes above. Running docker build --check . evaluates the Dockerfile against BuildKit's build checks, and one of them is named SecretsUsedInArgOrEnv, whose documented description reads: "Sensitive data should not be used in the ARG or ENV commands". A sibling check, CopyIgnoredFile, is described as "Attempting to Copy file that is excluded by .dockerignore", useful for catching a .dockerignore that quietly doesn't match what you think it matches. Run the check before you trust the output, not after something leaks.
Is a Dockerfile that builds a Dockerfile that's safe to ship?
No, and treating a successful build as the finish line is the single most common mistake in prompting AI for infrastructure code. docker build validates syntax and confirms each instruction executed; it has no opinion on whether the result runs as root, trusts a moving tag, or contains a token in a layer you can extract with docker history and a text editor. Read the generated file for, at minimum:
- A
USERline in the final stage, not just in a comment promising one - A pinned tag or digest on every
FROM, with no barelatest - Zero secrets, tokens, or passwords in any
ARGorENVvalue - A
.dockerignoresitting next to the Dockerfile, and aCOPYthat respects it - No
--privileged, no unnecessaryEXPOSEbeyond what the app actually listens on
None of these are exotic. All of them are things a model will skip by default because skipping them doesn't stop the build from succeeding.
How do you check that it actually runs, not just builds?
Building is the first checkpoint, not the last one. Three commands turn "it built" into "it works," and none of them touch anything outside the container itself:
docker build -t myapp:test .
docker run --rm -p 3000:3000 myapp:test
# in a second terminal:
curl http://localhost:3000/health
--rm deletes the container the moment you stop it, so nothing lingers on disk beyond the image you built. If the Dockerfile includes a HEALTHCHECK, Docker's own reference confirms the mechanism worth relying on here: any output the check command writes "will be stored in the health status and can be queried with docker inspect", so a failing health check is visible without writing a separate monitoring script. For a Compose file, the equivalent first move is docker compose config, which validates and prints the fully resolved configuration, secrets references and all, without starting a single container — a free way to catch a typo or a missing secrets: entry before anything runs at all.
What changed in Compose that a model prompt should know?
The most common stale habit in generated Compose files is a top-level version: line. Docker's Compose file reference is explicit that it is dead weight: "The top-level version property is defined by the Compose Specification for backward compatibility. It is only informative and you'll receive a warning message that it is obsolete if used." And, separately on the same page: "Compose always uses the most recent schema to validate the Compose file, regardless of the version field." A model trained on older tutorials will often still write version: "3.8" out of habit. Leave it out; nothing reads it.
What Compose does still validate against are its real top-level elements: services, networks, volumes, configs, and secrets. A Compose file that pairs the Dockerfile guidance above with those elements, and nothing stale, looks like this:
services:
web:
build: .
image: myapp:1.0
ports:
- "127.0.0.1:3000:3000"
env_file: .env
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "dist/healthcheck.js"]
interval: 30s
timeout: 5s
retries: 3
db:
image: postgres:18
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 10s
timeout: 5s
retries: 5
secrets:
db_password:
file: ./db_password.txt
volumes:
db_data:
Two details there are doing quiet security work. POSTGRES_PASSWORD_FILE reads the password from the mounted secret rather than the plaintext environment: block that AI output defaults to, using the exact top-level secrets: element pattern Docker's own Compose documentation demonstrates. And the port binds to 127.0.0.1:3000 rather than every interface, so the database and app aren't reachable from outside the host unless you deliberately widen it.
A prompt template for docker-compose.yml
Write a docker-compose.yml for [describe the services: app + database,
etc.]. Requirements:
- No top-level "version:" key.
- Use the "secrets:" top-level element for any password or API key,
referenced by services via the "secrets:" attribute, never a plain
"environment:" value.
- Bind published ports to 127.0.0.1 unless the service genuinely
needs to be reachable from other machines.
- Add a healthcheck for each service that has a meaningful readiness
signal, and use depends_on with condition: service_healthy where
one service must wait on another.
- Pin every image tag to a specific version, not "latest".
Stop rewriting prompts. Start shipping.
Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.
Create An AccountThe short version
An AI-written Dockerfile or Compose file that builds is a draft, not a deliverable. The gap between "it builds" and "it's safe to ship" is almost always the same five things: a root user nobody removed, a base image tag that can move under you, a secret sitting in ARG or ENV, a .dockerignore that doesn't exist, and a Compose file carrying a version: key that stopped meaning anything. Naming those constraints in the prompt fixes most of it before generation; running docker build --check . catches what the prompt didn't. Neither step takes longer than the build itself. For prompts that consistently ask for the constraint list above without retyping it every time, the Prompt Architects Chrome extension saves it as a reusable template alongside your other technical prompts — see our broader guide to 40 AI prompts for software development for the rest of that library, our code review prompt guide for reviewing the output a model hands back, and context engineering for coding agents for keeping constraints like these in an agent's context across a longer session.