GitHub Copilot for Docker
A Dockerfile is about twenty lines long, has roughly a dozen instructions, and is the most-written file format in modern infrastructure. Copilot writes them fluently.
It also writes them the way public code writes them, and public Dockerfiles are dominated by tutorials optimised for brevity. The result is a file that builds, runs, and is wrong in four ways that a linter will not mention.
Here is the measurement that opens this lesson. Two Dockerfiles for the same FastAPI application — the shape a first suggestion arrives in, and the reviewed version:
first draft 1.81 GB
reviewed 249 MBBoth produce a working container. One ships a compiler toolchain, a text editor, a package cache and a root user to production.
Key takeaways
hadolintfound six things in the draft Dockerfile and did not mention that the container runs as root. Linting is necessary and is not the review.- Multi-stage builds are the single highest-value change: they are what keeps build tooling out of the shipped layers.
USERtakes a numeric uid, not just a name, because KubernetessecurityContext.runAsUseris a number and the two have to agree.- The exec form of
CMDmatters operationally: the shell form makes PID 1 a shell that does not forwardSIGTERM, so the container ignores a graceful stop. - Never put a secret in an
ENV, anARG, or a copied file. Build arguments are visible indocker history.
Where Copilot helps, and where it stops
Genuinely good: turning a working local setup into a Dockerfile, translating
docker run flags into instructions, writing a .dockerignore from a project
listing, explaining someone else’s Dockerfile, and interpreting a build failure.
The last of those is underrated — BuildKit’s error output is precise and a model
reads it quickly.
Consistently needs correcting: base image selection, layer ordering, the runtime user, and anything to do with secrets. Those four are the review.
The first draft, and what the linter said
This is not a strawman. It is what you get from “write a Dockerfile for this Python app” with no further constraints:
FROM python:latest
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y curl vim
EXPOSE 8000
CMD uvicorn app.main:app --host 0.0.0.0 --port 8000hadolint 2.15.1 reported six findings:
DL3007 warning Using latest is prone to errors. Pin the version explicitly
DL3042 warning Avoid use of cache directory with pip. Use --no-cache-dir
DL3008 warning Pin versions in apt get install
DL3015 info Avoid additional packages by specifying --no-install-recommends
DL3009 info Delete the apt lists after installing something
DL3025 warning Use arguments JSON notation for CMD and ENTRYPOINTEvery one is correct. Now notice what is absent.
The reviewed Dockerfile
Practical example
Containerise a FastAPI application
A multi-stage build producing a 249MB image that runs as a non-root numeric uid and stops gracefully.
- Status
- Tested implementation
- Runtime
- Docker Engine 29.7.2, hadolint 2.15.1, python:3.12.12-slim
- Command
hadolint Dockerfile && docker build -t copilot-docker-demo:local . && docker run --rm -d -p 8000:8000 …- Result
- hadolint: no findings. Build succeeded, 249MB (draft: 1.81GB). GET /healthz returned 200 {"status":"ok","uptime_seconds":0.812,"version":"0.1.0"}. docker exec id reported uid=10001(app) gid=10001(app).
- Run on
- August 21, 2026
Files
copilot-docker-demo/ ├── app/ │ └── main.py ├── requirements.txt ├── Dockerfile ├── Dockerfile.draft the first suggestion, kept for comparison ├── .dockerignore ├── README.md └── .github/ ├── copilot-instructions.md └── instructions/ └── docker.instructions.md
The prompt
Create a multi-stage Dockerfile for this Python FastAPI application.
Requirements:
- Pin the base image to an exact patch tag of python slim. Never latest.
- Stage one installs dependencies into a virtualenv; stage two copies only that virtualenv into a clean image.
- Copy requirements.txt on its own before the application source, so editing source does not invalidate the install layer.
- Create a numeric user and group and end with USER uid:gid.
- Use the exec form for CMD.
- No secrets in ENV, ARG or any copied file.
Then run hadolint and report every finding.
The final clause changes the interaction. Without it you get a file; with it you get a file and the evidence.
Implementation
# syntax=docker/dockerfile:1
# ---------- build ----------
FROM python:3.12.12-slim AS build
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# Dependencies are copied on their own so that editing application source does
# not invalidate the layer that installs them.
COPY requirements.txt ./
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
# ---------- runtime ----------
FROM python:3.12.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \
APP_VERSION=0.1.0
# A fixed uid/gid, not a name lookup: Kubernetes securityContext.runAsUser
# needs a number, and the two must agree.
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=build /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
EXPOSE 8000
# Exec form. The shell form would make PID 1 a shell that does not forward
# SIGTERM, so the container would ignore a graceful stop and be killed.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]The .dockerignore
Frequently omitted from suggestions, and it does two jobs at once — it keeps the build context small, and it makes it structurally impossible for a local credential file to end up in a layer.
.git
.github
.venv
__pycache__/
*.pyc
.pytest_cache/
.env
.env.*
*.log
README.md
Dockerfile
.dockerignoreObserved output
$ hadolint Dockerfile
(no output, exit 0)
$ docker build -t copilot-docker-demo:local .
(success)
$ docker images copilot-docker-demo --format "{{.Tag}}: {{.Size}}"
draft: 1.81GB
local: 249MB
$ curl -s localhost:8000/healthz
{"status":"ok","uptime_seconds":0.812,"version":"0.1.0"}
$ docker exec <id> id
uid=10001(app) gid=10001(app) groups=10001(app)The instructions that stop you repeating yourself
Four corrections recur in every generated Dockerfile, so they belong in a file rather than in every review. This is the path-scoped instructions file from the example, and it is short on purpose.
---
applyTo: "**/Dockerfile,**/Dockerfile.*,**/.dockerignore"
---
- Pin the base image to an exact patch tag. Never `:latest`, never a bare major.
- Use a multi-stage build. Compilers, headers and package caches belong in a
stage that is discarded.
- Copy the dependency manifest alone and install from it before copying source.
- Create a numeric user and end the runtime stage with `USER <uid>:<gid>`.
- Use the exec form for `CMD` and `ENTRYPOINT`.
- Never place a secret in `ENV`, `ARG`, or a `COPY`ed file.
- Prefer `COPY` to `ADD`.
- Combine `apt-get update` and `install` in one `RUN`, pass
`--no-install-recommends`, and delete `/var/lib/apt/lists` in the same layer.
After any edit, run `hadolint Dockerfile` and report every finding.Validation
Validate before deploying
Docker: the local validation sequence
| Step | Command | Executed here |
|---|---|---|
| Lint | hadolint Dockerfile | PASSNo findings on the final Dockerfile; 6 findings on the first draft |
| Build | docker build -t copilot-docker-demo . | PASSBuilt successfully; final image 249MB, against 1.81GB for the first draft |
| Run and probe | docker run --rm -d -p 8000:8000 … then curl /healthz | PASSHTTP 200, {"status":"ok","uptime_seconds":0.812,"version":"0.1.0"}; docker exec id reported uid=10001(app) |
Layer caching, and why order is a design decision
The rule is one line: instructions are cached until one changes, and everything after a changed instruction re-runs.
The consequence is that COPY . . before RUN pip install means every source
edit reinstalls every dependency. Splitting the copy — manifest first, install,
then source — is why the example’s build is fast on the second run and why the
draft’s is not.
Two further caching points that generated Dockerfiles miss:
Combine related RUN steps, separate unrelated ones. apt-get update in one
layer and apt-get install in the next means the install can use a cached,
stale package index. They belong in one RUN. Conversely, a slow step that
rarely changes should not share a layer with a fast one that changes often.
Use BuildKit cache mounts for package managers where the build is frequent:
RUN --mount=type=cache,target=/root/.cache/pip pip install … keeps the
downloads between builds without putting them in a layer. Generated Dockerfiles
rarely reach for this, and it is worth asking for by name.
Image size, and what actually causes it
The 1.81GB figure is worth decomposing, because the intuition “use alpine” is the wrong lesson.
The draft’s size came from three decisions, in descending order of cost:
python:latest rather than a slim variant, no multi-stage split so pip’s build
dependencies stayed, and apt-get install curl vim with the package lists left
in place. Changing the base image alone would not have fixed it; the multi-stage
split is what removes the build layer entirely.
Health checks
A HEALTHCHECK instruction is useful in Compose and largely ignored in
Kubernetes, which uses its own probes. Generated Dockerfiles either omit it or
add one that curls an endpoint using a curl binary the slim image does not
have.
If you add one, use the language runtime you already have rather than installing a tool for it. The Compose example in the next lesson uses a one-line Python urllib call for exactly this reason.
Base images: the decision made for you
The FROM line is the single most consequential instruction in the file, and it
is the one a suggestion picks for you with the least information.
What “latest” actually means. It is a tag like any other, not a resolution to
the newest release. It points at whatever the publisher last pushed it at. A
build that succeeded in March and fails in September with a syntax error is
usually :latest having moved across a major version boundary. Generated
Dockerfiles reach for it because tutorials do, and tutorials do because they are
not maintained.
Slim, full, and alpine are three different trades. The full Debian-based
images carry a complete toolchain and are convenient for building. The -slim
variants drop most of it and are the right runtime default for interpreted
languages. Alpine uses musl rather than glibc, which for Python means many
packages have no prebuilt wheel and compile from source — a smaller image bought
with slower builds and occasional runtime differences that are genuinely hard to
debug.
Distroless images contain the runtime and nothing else — no shell, no package
manager, no ls. They are an excellent production target and an awkward
debugging one, because docker exec into them gives you nothing. If you use
them, the debugging story has to be logs and ephemeral debug containers rather
than a shell.
The registry matters as much as the name. python on Docker Hub is an
official image. python-fast or python-slim-optimized is somebody’s fork, and
a suggestion that names one is worth reading character by character. This is the
same supply-chain reasoning as
the npm section in Cluster 3, and the
namespace is the thing to check.
What to do about it: state the base image in the prompt, pin it to a patch tag, and for anything you deploy, pin it by digest. A digest is immutable, which is the property you actually wanted from a version number.
The instructions, and what each one really does
Copilot gets most of these right most of the time. These are the ones where the default is subtly wrong.
WORKDIR creates the directory and it is absolute. A suggestion using
RUN cd /app instead does not persist between instructions — each RUN is a new
shell — so the next instruction runs somewhere else entirely. WORKDIR is the
only correct answer.
COPY --chown avoids an extra layer. Copying as root and then running
RUN chown -R duplicates the entire tree in a second layer. Generated
Dockerfiles do this routinely, and on a large application it is the difference
between a 200MB image and a 400MB one.
ADD does three things and you probably want one. It copies, it fetches
remote URLs, and it auto-extracts archives. The extraction behaviour surprises
people, and the remote fetch happens at build time with no verification. Use
COPY unless you specifically want one of the other two.
ENV persists into the running container. ARG does not — but as covered
below, it persists into the image metadata, which is worse in a different way.
EXPOSE publishes nothing. It is documentation, read by docker run -P and
by humans. A generated EXPOSE 8000 on a service listening on 8080 is a comment
that lies.
ENTRYPOINT plus CMD is the pattern for a wrapper. ENTRYPOINT is the
fixed command; CMD supplies default arguments that a docker run invocation
can replace. Suggestions frequently use one where the other belongs, which is
harmless until someone tries to pass an argument.
Containers in a pipeline
A Dockerfile rarely stays a local artefact, and two properties matter once it reaches CI.
Reproducibility. A build that produces a different image from the same commit
is a build you cannot reason about. The inputs to fix are the base image tag, the
dependency manifest, and any apt-get install without a version. Pinning all
three is what makes “the image CI tested” and “the image production runs” the
same statement.
Provenance. The link between a commit and an image is a digest, not a tag.
The capstone in
Build an AI-Powered CI/CD Pipeline
captures the digest with docker build --iidfile and passes it forward as a job
output, so the deployment references the exact image the tests ran against. A
pipeline that builds myapp:latest and then deploys myapp:latest has no such
link — the two are separate pulls of a moving pointer.
Two smaller CI-specific points worth asking for by name, because suggestions
omit both: build caching between runs, which for GitHub Actions means
docker/build-push-action with a cache backend or a BuildKit cache mount; and a
smoke test in the build job. The capstone’s container job starts the image it
just built and curls the health endpoint before anything downstream is allowed to
proceed. It takes eight seconds and it catches the class of failure where the
image builds perfectly and the process exits immediately.
Debugging a build
Copilot is good here, and the useful prompt is specific.
This Docker build fails. Here is the Dockerfile and the full BuildKit output, including the failing step and its exit code.
Tell me which instruction failed and why, whether it is a caching issue, and what the minimal change is. Do not rewrite the whole file.
“Do not rewrite the whole file” matters. Asked to fix a build error, a model will frequently return a completely restructured Dockerfile, and now you are reviewing twenty lines instead of one.
For inspecting a built image, docker history --no-trunc shows every layer and
its size, which is how you find the instruction responsible for a surprise. It
is also how you find a secret: every build argument appears there.
Multi-architecture builds
Worth a short section because it is now the default source of a specific, confusing failure: an image built on an Apple Silicon laptop that will not start on an x86 cluster, or the reverse.
docker build produces an image for the architecture of the machine that ran it.
Nothing warns you. The image pushes fine, pulls fine, and fails at startup with
exec format error, which reads like a corrupt binary rather than an
architecture mismatch.
Generated Dockerfiles never mention this because the Dockerfile is not where it
is decided — the build command is. The fix is docker buildx with an explicit
platform list:
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:0.1.0 .Two things to check in any suggestion that touches a multi-platform build. A
FROM line with a hard-coded --platform=linux/amd64 pins the build stage to
one architecture and quietly defeats the whole exercise; it is occasionally
correct, for a stage that runs a prebuilt tool, and usually a mistake. And any
RUN step that downloads a binary needs to select by architecture rather than
assuming — TARGETARCH is the build argument BuildKit provides for exactly that,
and a suggestion hard-coding amd64 in a download URL produces an arm64 image
containing an x86 binary.
If you only ever deploy to one architecture, say so in your instructions file and build for it explicitly. The failure mode of being implicit is a container that works on every machine except the one that matters.
Docker-specific risks
Running as root. No USER, or a USER set before a RUN that needs
privileges and never restored. The most common and least-flagged defect.
:latest and floating tags. A rebuild six months later produces a different
image. Pin to a patch tag; in Kubernetes, pin by digest.
Secrets in layers. Covered below.
COPY . . with a thin .dockerignore. The build context is whatever is in
the directory.
Unpinned package installs. apt-get install -y curl installs whatever
version the index has today.
Writable root filesystem assumptions. Code that writes to /app or /tmp
breaks the moment Kubernetes sets readOnlyRootFilesystem: true. Decide where
it writes and mount that explicitly.
The shell form of CMD. PID 1 becomes /bin/sh -c, signals are not
forwarded, and docker stop waits ten seconds and then kills the container.
Destructive commands
Generated cleanup scripts reach for this because it is the one command that reliably frees space. It also deletes the local database of every project on the machine that is not currently running.
Review workflow
- Run hadolintSeconds, and it clears the mechanical layer — style, pinning, apt hygiene.
- Find the USER instructionHuman judgementIf there isn't one, the container runs as root. hadolint will not tell you.
- Check the build contextHuman judgementIs there a .dockerignore, and does it exclude .git, .env and local credentials?
- Look for a multi-stage splitHuman judgementDoes the runtime stage contain a compiler, a package cache, or a text editor?
- Grep the diff for ARG and ENVHuman judgementAnything that looks like a token, key or password is in the image history.
- Build it, then run docker history --no-truncConfirms the layer sizes and shows every build argument.
- Scan the imageTrivy or docker scout. This is a question about the base image, not the Dockerfile.
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Ask for a multi-stage build and a numeric
USERby name; neither is the default. - Pin the base image to a patch tag, and to a digest for anything deployed.
- Put the four recurring corrections in
.github/instructions/docker.instructions.mdrather than in every review. - Write the
.dockerignorefirst. It is the cheapest security control here. - Use BuildKit secret mounts for build-time credentials, never
ARG. - Ask for the
hadolintoutput alongside the file.
Common mistakes
- Treating a clean
hadolintrun as a completed review. - Accepting
COPY . .without checking what ”.” contains. - Using
ARGfor a token because it feels transient. - Leaving the shell form of
CMD, then wondering why deploys take ten seconds longer than they should. - Assuming a smaller base image is the fix when the real cost is a missing multi-stage split.
Where to go next
GitHub Copilot for Docker Compose takes this image and wires it to a database and a cache. GitHub Copilot for Kubernetes is where the numeric uid and the read-only filesystem stop being style and start being admission requirements. For the language side, GitHub Copilot for Python covers the application this example packages.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.