GitHub Copilot for Docker

GitHub Copilot for DevOps & InfrastructureAcademy lesson 40Cluster 4 · Lesson 2 of 13Intermediate14 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for DockerGitHub Copilot for DevOps & Infrastructure2Intermediate/github-copilot/devops/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 MB

Both produce a working container. One ships a compiler toolchain, a text editor, a package cache and a root user to production.

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 8000

hadolint 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 ENTRYPOINT

Every 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

examples/cluster-4/docker

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

Copilot promptGenerate the DockerfileCopilot Chat, with requirements.txt open

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

Observed 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

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.

Copilot promptDebugging a failed buildCopilot Chat

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

Accepting a generated Dockerfile
  1. Run hadolintSeconds, and it clears the mechanical layer — style, pinning, apt hygiene.
  2. Find the USER instructionHuman judgementIf there isn't one, the container runs as root. hadolint will not tell you.
  3. Check the build contextHuman judgementIs there a .dockerignore, and does it exclude .git, .env and local credentials?
  4. Look for a multi-stage splitHuman judgementDoes the runtime stage contain a compiler, a package cache, or a text editor?
  5. Grep the diff for ARG and ENVHuman judgementAnything that looks like a token, key or password is in the image history.
  6. Build it, then run docker history --no-truncConfirms the layer sizes and shows every build argument.
  7. 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 USER by 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.md rather than in every review.
  • Write the .dockerignore first. It is the cheapest security control here.
  • Use BuildKit secret mounts for build-time credentials, never ARG.
  • Ask for the hadolint output alongside the file.

Common mistakes

  • Treating a clean hadolint run as a completed review.
  • Accepting COPY . . without checking what ”.” contains.
  • Using ARG for 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.

Primary sources