Getting ChatGPT to Write Accurate Dockerfile Multi-Stage Builds Without Bloated Images
You paste a quick request into ChatGPT: "Write a Dockerfile for my Python API." Thirty seconds later you have something that works β it builds, it runs, and it ships a 1.4 GB image with pip, gcc, test dependencies, and your app running as root. That's not production-ready; it's a liability.
The problem isn't that ChatGPT can't write good Dockerfiles. It can. The problem is that its default output optimizes for "does it run?" rather than "is it lean and safe?" A few targeted prompt patterns change that entirely.
What You'll Learn
- Why ChatGPT defaults to single-stage, bloated Dockerfiles and what triggers better output
- How to structure a prompt that forces a proper multi-stage build with a minimal final image
- Which specific sections of the output to audit before you use it
- How to get ChatGPT to handle layer caching, non-root users, and pinned base images correctly
- The common gotchas that slip through even well-prompted output
Prerequisites
- Basic familiarity with Docker and the
docker buildcommand - A working Docker installation (version 20+ recommended for BuildKit support)
- An application to containerize β examples here use Python, but the patterns translate to Node.js and Go as well
- Access to ChatGPT (GPT-4 class models produce noticeably better Dockerfiles than older versions)
Why ChatGPT's Default Dockerfiles Are Too Fat for Production
When you ask for a Dockerfile without constraints, ChatGPT reaches for the most familiar pattern: a single FROM python:3.11 (the full image, not -slim), installs everything including build tools, copies the entire project directory, and runs the server. That image often clears 800 MB to 1.4 GB depending on what gets pulled in.
There are three root causes. First, ChatGPT is trained on millions of public Dockerfiles, many of which are tutorials and quick-start examples not meant for production. Second, it doesn't know your deployment environment unless you tell it β a fat image is "safer" in the sense that it's less likely to be missing a dependency. Third, multi-stage builds require the model to reason across multiple FROM blocks and track which artifacts move between stages; this is exactly the kind of structured, stateful reasoning that benefits from explicit prompting.
The fix is to shift the prompt from a description of what you want to run into a specification of the constraints the output must satisfy.
How ChatGPT Typically Fails at Multi-Stage Builds
Even when you ask for a multi-stage build by name, the output often has subtle issues:
- Wrong base image in the final stage. The model might copy from a builder that used
python:3.11but usepython:3.11-slimin the final stage without confirming the shared library dependencies line up. - Dev dependencies in production. It copies
requirements.txtwholesale and installs everything, instead of using separaterequirements-prod.txtandrequirements-dev.txtfiles. - Root user in the final stage. The most common security miss β no
USERdirective, so your app runs as root inside the container. - Unpinned base image tags.
FROM python:3.11-slimwithout a digest or at least a patch version, meaning the next build may pull a different image. - Bad
COPYordering. ChatGPT sometimes copies the full application before copying and installing dependencies, killing layer cache reuse on every code change. - Missing
.dockerignore. Without one, the build context includes.git,__pycache__, local.envfiles, and test data.
These aren't model bugs β they're missing constraints. You didn't tell it what "good" looks like, so it shipped the average.
Crafting a Prompt That Forces a Lean Build
The most reliable pattern is a constraint-driven prompt that lists explicit requirements as a numbered checklist. This format activates a more structured output from GPT-4 class models than a natural-language paragraph does.
Here's a template you can adapt:
Write a production Dockerfile for a Python 3.11 FastAPI application.
Follow these requirements exactly:
1. Use a multi-stage build with exactly two stages: `builder` and `runtime`.
2. Builder stage: use `python:3.11-slim` to compile dependencies.
- Copy only `requirements.txt` first, run `pip install --no-cache-dir -r requirements.txt`.
- Then copy application source.
3. Runtime stage: use `python:3.11-slim` (same minor version as builder).
- Copy only the installed site-packages from builder, not the full Python tree.
- Copy application source from builder.
4. Do NOT install dev or test dependencies in the runtime stage.
5. Create a non-root user (`appuser`, UID 1001) and switch to it before CMD.
6. Expose port 8000. Use `CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]`.
7. Pin the base image to a specific digest or at minimum the full patch version tag.
8. Order COPY and RUN instructions to maximize layer cache reuse.
9. Add a brief comment above each stage explaining its purpose.
10. After the Dockerfile, provide a matching .dockerignore file.
The key techniques here are: specifying stage names (so the model can reason about COPY --from=builder accurately), calling out the non-root user explicitly, demanding a matching .dockerignore, and requiring comments (which force the model to make its reasoning visible so you can audit it).
A well-prompted response for a FastAPI app should look roughly like this:
# Stage 1: builder β install dependencies in an isolated layer
FROM python:3.11.9-slim AS builder
WORKDIR /app
# Copy dependency manifest first to leverage cache on code-only changes
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Copy application source after dependencies are locked in
COPY . .
# Stage 2: runtime β minimal image with only what the app needs to run
FROM python:3.11.9-slim AS runtime
WORKDIR /app
# Pull installed packages from builder stage only
COPY --from=builder /install /usr/local
COPY --from=builder /app /app
# Create non-root user for security
RUN addgroup --system appgroup && \
adduser --system --ingroup appgroup --uid 1001 appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Notice the --prefix=/install flag on pip install. This installs packages into a clean, isolated directory that you can then copy as a single layer into the runtime stage β a cleaner pattern than copying the entire /usr/local tree from the builder.
Auditing the Output: What to Check Before You Ship
Even a well-prompted response needs a quick review pass. Run through this checklist mentally (or literally paste it into a follow-up prompt and ask ChatGPT to self-audit):
- Base image versions match across stages. If builder is
python:3.11.9-slimand runtime ispython:3.12-slim, your copied bytecode may be incompatible. - No build tools in the final stage. Run
docker run --rm your-image dpkg -l | grep gccto confirm gcc isn't present. - App is not running as root. Run
docker run --rm your-image whoamiβ should print your non-root username, notroot. COPYorder preserves cache. Dependencies must be copied and installed before application source is copied. If you seeCOPY . .beforepip install, the cache breaks on every save.- The
.dockerignoreexcludes sensitive files. At minimum:.git,*.env,__pycache__,.pytest_cache,tests/,*.pyc.
You can also ask ChatGPT to calculate the expected final image size before you build. It won't be perfectly accurate, but asking the model to reason about what each COPY brings in often surfaces layers it forgot to exclude.
This kind of systematic review is just as important when prompting for infrastructure configs as it is for application code β the same habit applies when you're getting ChatGPT to write Kubernetes manifests, where stale API versions cause silent breakage at apply time.
Hardening the Final Stage: Security Defaults ChatGPT Skips
Beyond image size, a lean Dockerfile is also a more secure one. Here are the additions most ChatGPT outputs omit unless you ask explicitly:
Drop Linux capabilities
Adding --cap-drop ALL at runtime (in your Docker Compose or Kubernetes pod spec) removes all Linux capabilities from the container by default. If you run ChatGPT's output as-is in Kubernetes, the pod inherits whatever the node allows. Ask ChatGPT to generate a companion pod security context alongside the Dockerfile β it will include the right securityContext block.
Read-only filesystem
If your application doesn't need to write to disk at runtime (most stateless APIs don't), prompt ChatGPT to design the final stage for a read-only root filesystem. Ask it to identify which paths need to be writable (typically /tmp and log directories) and mount those as tmpfs volumes. The model handles this well when prompted directly.
HEALTHCHECK directive
ChatGPT almost always forgets this. Add a prompt line: "Include a HEALTHCHECK instruction using curl or wget to probe the /health endpoint every 30 seconds." A Dockerfile with no HEALTHCHECK means Docker can't tell a crashed app from a running one.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
Layer Caching Strategy β Getting ChatGPT to Think in Order
Layer caching is where a lot of AI-generated Dockerfiles lose practical value. If your cache busts on every build because COPY . . runs before pip install, you're waiting for a full dependency install on every CI push.
Tell ChatGPT explicitly: "Explain the caching strategy before writing the Dockerfile. Which layers change most frequently? Least frequently?" This forces the model to reason about the dependency graph first. Typically the answer is:
- Rarely changes: OS packages, Python version, base system libs
- Changes occasionally:
requirements.txt - Changes constantly: Application source code
The COPY order should mirror that sequence, slowest-to-change first. When ChatGPT reasons this out before writing, the resulting instruction order is almost always correct.
For Node.js projects, the equivalent is copying package.json and package-lock.json before copying src/. For Go, copy go.mod and go.sum before the rest of the source. Ask ChatGPT to spell out the language-specific equivalent for your stack.
This systematic reasoning approach β making the model explain before it generates β is the same technique that reduces hallucinations in ChatGPT-generated Nginx configs, where instruction order directly affects routing behavior.
Common Pitfalls and How to Catch Them
The "same tag, different digest" trap
Pinning to python:3.11.9-slim is better than python:3.11-slim, but the tag can still be moved by the registry. For genuine reproducibility, use digest pinning: FROM python:3.11.9-slim@sha256:<digest>. Ask ChatGPT to generate the command to fetch the current digest: docker pull python:3.11.9-slim && docker inspect python:3.11.9-slim --format='{{index .RepoDigests 0}}'. It knows this command; it just won't volunteer it.
Missing shared libraries in the runtime stage
If your Python packages compile C extensions (psycopg2, Pillow, cryptography), copying site-packages alone isn't enough β you also need the shared libraries those extensions link against. Prompt ChatGPT: "List any system libraries that must be installed in the runtime stage for these packages: [list your C-extension packages]." The model is reasonably good at this lookup when asked directly.
WORKDIR inconsistency between stages
Each stage starts fresh. If your builder sets WORKDIR /app but the runtime stage forgets it and COPY --from=builder /app . targets the implicit root, your paths break. ChatGPT occasionally misses this across long Dockerfiles. Always check that both stages have an explicit WORKDIR directive.
Environment variables leaking secrets
ChatGPT sometimes generates ENV DATABASE_URL=... as a placeholder. Placeholders get committed. Prompt it to use ARG for build-time values that don't belong in the final image, and remind it that ARG values after a FROM instruction are not inherited automatically β each stage needs its own ARG declaration if it uses the value.
The general pattern of keeping secrets out of generated configs is something to watch for across all infrastructure output. It comes up directly in prompting for OAuth 2.0 flows, where token handling requires the same discipline about what never lives in a file.
Over-trusting "distroless" suggestions
Distroless images (like gcr.io/distroless/python3) are genuinely lean and secure, but they have no shell, no package manager, and limited debugging tools. ChatGPT may suggest them without flagging these tradeoffs. If you ask for distroless, also ask: "What debugging steps are no longer possible with a distroless image, and what are the alternatives?" You should get a useful breakdown of docker debug, ephemeral debug containers, and sidecar patterns.
Next Steps
With a solid prompt pattern and an audit checklist in hand, here's what to do next:
- Build and measure your baseline. Run
docker images your-imageand record the size before and after applying these prompt patterns. A typical Python API should drop from 900 MB+ to under 200 MB with a properly staged build on-slim. - Add a Makefile target for size auditing. Something like
make docker-sizethat runsdocker history your-imageanddocker inspect --format='{{.Size}}' your-imageas part of CI. Ask ChatGPT to write that target β it's a good test of whether the layer reasoning carried over. For reference on prompting Makefile targets cleanly, see getting ChatGPT to write accurate Makefile targets. - Run a container security scanner. Pipe your built image through
trivy image your-imageordocker scout cves your-imageand feed the output back to ChatGPT: "Here are the CVEs in my image layers. Which relate to packages I can remove from the runtime stage?" This is one of the most practical uses of the model in a Docker workflow. - Version-lock your prompt template. Save your working constraint list in a
PROMPT.mdfile in your repo. When your stack changes (new runtime, new package), update the constraints and re-run. Treating the prompt as code β with version history β keeps your generated Dockerfiles consistent across your team. - Extend the pattern to your CI pipeline. If you're running builds in GitHub Actions or GitLab CI, ask ChatGPT to generate the build job alongside the Dockerfile, with
--cache-fromflags pointing to your registry. That's where layer caching actually pays off at scale.
Frequently Asked Questions
How do I stop ChatGPT from including dev dependencies in my production Docker image?
Explicitly tell ChatGPT to use separate requirements files β one for production and one for development β and instruct it to install only the production file in the runtime stage. Adding a checklist item like "Do NOT install test or dev packages in the runtime stage" to your prompt reliably prevents this.
What base image should I use in the final stage of a Python multi-stage Docker build?
Use python:3.x.y-slim for the runtime stage, matching the exact minor version used in the builder stage to avoid bytecode compatibility issues. For the smallest possible image, distroless is an option, but it removes shell access and makes debugging significantly harder.
How can I check if my Docker image is running as root after building it?
Run docker run --rm your-image whoami after the build. If it prints 'root', the Dockerfile is missing a USER directive. Add a non-root user with adduser or useradd and switch to it before the CMD instruction.
Why does my Docker build reinstall all dependencies every time I change a source file?
This happens when COPY . . appears before pip install in the Dockerfile β any file change busts the layer cache and triggers a full reinstall. Always copy the requirements file first, run the install, and then copy application source code so dependency layers are reused on code-only changes.
Can ChatGPT generate a .dockerignore file alongside a Dockerfile?
Yes, and you should always ask it to. Add an explicit line to your prompt like 'After the Dockerfile, provide a matching .dockerignore file.' Without this, ChatGPT typically omits it, and your build context will include .git directories, local .env files, and test caches that inflate the build and may leak secrets.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!