Back to blog
Engineering12 min read

Prompting for Dockerfiles and Compose

AI writes a Dockerfile that builds in seconds. Here's the prompt to ask for a multi-stage, non-root, secret-safe one, and the checklist to read the output like a reviewer, not a rubber stamp.

NH
Nafiul Hasan
Founder, Prompt Architects

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.

ProblemWhat a model tends to writeThe fix
Runs as rootNo USER line at allAdd USER <name> in the final stage, after creating the user
Unpinned base imageFROM node or FROM node:latestPin a tag (node:22-slim), or a digest for full reproducibility
Secrets baked into the imageARG API_KEY or ENV API_KEY=...RUN --mount=type=secret,id=api_key at build time
Whole directory copied inCOPY . . with no .dockerignoreA .dockerignore excluding .git, .env, and local secrets
No health signalNo HEALTHCHECK instructionAdd 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 USER line in the final stage, not just in a comment promising one
  • A pinned tag or digest on every FROM, with no bare latest
  • Zero secrets, tokens, or passwords in any ARG or ENV value
  • A .dockerignore sitting next to the Dockerfile, and a COPY that respects it
  • No --privileged, no unnecessary EXPOSE beyond 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".
Free Chrome Extension

Stop rewriting prompts. Start shipping.

Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.

Create An Account

The 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.

Frequently asked questions

Free Chrome Extension

Stop rewriting prompts. Start shipping.

Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.

Create An Account