Skip to content

GitHub Copilot CLI for Docker

Lesson 8 of 9Intermediate → Advanced12 min readGitHub Copilot & AI Engineering · Copilot CLIVerified: Docker CLI conventions and GitHub Copilot CLI permission model, September 2026

Container work splits cleanly into two halves, and AI fits one of them very well.

Authoring and debugging — writing a Dockerfile, working out why a build fails, understanding why an image is 1.2GB — is dense, mechanical, and produces output nobody enjoys reading. Ideal.

Cleanup and registry operations delete things, sometimes far more than intended. docker system prune is one flag away from removing considerably more than you meant.

The highest-value use. Build output is long, the actual error is buried, and the surrounding context matters.

Terminal window
docker build -t myapp:dev . 2>&1 | tail -50

This build fails. Here is the Dockerfile and the last 50 lines of output. What is wrong?

An agent that can run the build itself is better again — it reads the whole output rather than the tail you chose, and it can iterate: change, rebuild, read, adjust.

The failures it handles well:

Layer caching confusion. A COPY . . before RUN npm ci invalidates the dependency cache on every source change. This is the most common Dockerfile performance bug and it is immediately visible to a reader who knows to look.

Missing build context. A file referenced in the Dockerfile that .dockerignore excludes. The error is unhelpful and the cause is one line in another file.

Platform mismatches. A base image built for a different architecture, producing an error about exec format that names nothing useful.

Dependency resolution inside the build. A failure in npm ci or pip install that scrolls past several hundred lines of unrelated output.

Generated Dockerfiles have consistent weaknesses, all of which come from documentation examples being minimal rather than production-shaped.

Layer ordering. Dependencies before source, so a code change does not reinstall everything:

COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/

Generated versions frequently do COPY . . first, which is simpler and defeats the cache.

Running as root. Almost every minimal example does. The fix is two lines and rarely appears unprompted:

RUN useradd --system --uid 10001 appuser
USER 10001

A numeric USER matters for Kubernetes, where runAsNonRoot checks the numeric UID rather than the name.

Unpinned base images. FROM node:22-alpine is a moving target. Pinning by digest makes builds reproducible:

FROM node:22-alpine@sha256:YOUR_DIGEST_HERE

The objection — that a pinned base never receives security updates — is an argument for updating the pin deliberately rather than for not pinning. Dependabot’s docker ecosystem opens a pull request when the base image moves.

No .dockerignore. Without one, COPY . . copies .git, node_modules, local .env files and build artefacts into the image — which is both a size problem and, for .git and .env, a disclosure.

.dockerignore
.git
.env
.env.*
node_modules
*.log

Where an agent helps with something genuinely fiddly.

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
RUN useradd --system --uid 10001 appuser
USER 10001
CMD ["node", "dist/server.js"]

The build toolchain, the dev dependencies and the source never reach the final image. Asking an agent to convert a single-stage Dockerfile to multi-stage is a well-defined task with a checkable result: the image gets smaller and still runs.

The check that matters: does the runtime stage contain anything it does not need? A COPY --from that brings across the whole /app rather than /app/dist defeats the point, and it looks correct.

A good analysis task, because the data is available and reading it is tedious.

Terminal window
docker history myapp:dev --no-trunc

What it doesShows the layers of an image with the command that created each and its size.

Why we run itImage size problems are layer problems, and this is the data that identifies which layer.

Expected resultOne row per layer, with size and the truncated command.

Here is the Dockerfile and docker history. Which layers are largest, why, and what would reduce them?

The findings are usually the same handful — build tools left in the final image, a package cache not cleaned in the same RUN layer, the full source copied when only the build output is needed — and an agent identifies them reliably.

The subtle one worth knowing: deleting a file in a later layer does not shrink the image. Layers are additive; a file added in layer three and removed in layer five is still in the image. The fix is not to add it, which means combining the install and cleanup into one RUN.

The operational half, and where an agent saves the most time during an incident.

Terminal window
{/* Recent logs, with timestamps */}
docker logs --tail 200 --timestamps CONTAINER
Terminal window
{/* Why did it exit? */}
docker inspect CONTAINER --format '{{.State.ExitCode}} {{.State.Error}} {{.State.OOMKilled}}'

That second command answers three questions at once, and the third — OOMKilled — is the cause of a large proportion of “the container just died” reports. An agent that knows to check it gets to the answer faster than log-reading does.

The patterns worth asking about:

Restart loops. docker ps -a shows restart counts. A container restarting every thirty seconds is usually failing a health check or crashing at start-up, and the logs from before the last restart are the useful ones.

Exit codes. 137 is SIGKILL, typically out-of-memory. 139 is a segmentation fault. 1 is the application deciding to exit. These narrow the search substantially and are not obvious.

Silence. A container producing no logs at all usually means output is going somewhere other than stdout, which is a configuration problem rather than an application one.

The caution from the DevOps lesson applies: container logs contain data. Request bodies, customer identifiers, occasionally tokens. An agent reading a production log sends it to a provider.

Pattern-shaped checks that an agent applies consistently:

  • Running as root
  • Unpinned or :latest base images
  • Secrets in ENV or build arguments — visible in docker history
  • Package caches and build tools left in the final image
  • Missing HEALTHCHECK
  • Overly broad COPY

The one that surprises people: build arguments are visible in the image metadata. A secret passed with --build-arg is recoverable from docker history. Use BuildKit secret mounts instead, which do not persist into the image.

Pair the review with deterministic scanning — Trivy or Grype in CI find CVEs reliably, which an agent reading a Dockerfile does not.

Multi-service local environments, where the failure modes are configuration rather than code.

Where an agent helps: explaining an inherited compose.yaml, diagnosing why a service cannot reach another, and reasoning about startup ordering.

The recurring confusion it resolves quickly: depends_on waits for the container to start, not for the service inside it to be ready. A database container that is running but not yet accepting connections satisfies depends_on and fails the application. The fix is a health check with condition: service_healthy, and it is the answer to a large proportion of “it works on the second try” reports.

Container work in a pipeline is where the generated Dockerfile ends up, and a few things transfer badly.

Build caching differs. A build that is fast locally because of a warm layer cache may be slow in CI where every run starts cold. Docker CI covers cache backends; the point here is that “it builds quickly” is not a property that survives the move.

The build context is different. CI checks out the repository fresh; your local directory has untracked files, editor state and possibly a .env. A COPY . . behaves differently in the two places, and the local version is the more dangerous one.

Platform matters. A local build on an ARM laptop produces an ARM image. CI probably builds x86, and a Dockerfile that works locally can fail there — or produce an image that will not run on your cluster. Asking about --platform explicitly is worth doing.

Secrets work differently. Local --build-arg habits do not transfer; CI needs BuildKit secret mounts or a runtime secret, and the Actions secrets model.

A useful question when a Dockerfile is about to move from local to CI:

This Dockerfile works locally. What would behave differently in a CI build — caching, context, platform, secrets?

That is a question with a specific, checkable answer, and it catches the class of problem that otherwise appears as a mysterious CI-only failure.

docker system prune. Removes stopped containers, unused networks, dangling images — and with -a every image not currently in use, with --volumes your data. This is a command to type yourself, having read what it will remove.

Terminal window
{/* See what would go, without removing it */}
docker system df
docker image ls -f dangling=true

docker volume rm. Volumes hold data. There is no undo.

Registry deletions. Removing a published tag or image affects whoever depends on it, and interacts badly with signatures and attestations, which are stored alongside images and can be garbage-collected with them.

Pushing images. A push publishes. See Docker CD for where that belongs.

Anything against a production registry or host. The safeguards from the DevOps lesson apply unchanged.

The Dockerfile worth asking for, rather than the one you get by default. This is a Node example; the shape transfers.

{/* Build stage — toolchain and dev dependencies live here and go no further */}
FROM node:22-alpine@sha256:YOUR_DIGEST_HERE AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
{/* Runtime stage — production dependencies and build output only */}
FROM node:22-alpine@sha256:YOUR_DIGEST_HERE AS runtime
WORKDIR /app
RUN addgroup -g 10001 -S app && adduser -u 10001 -S app -G app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER 10001
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s CMD node -e "require('http').get('http://localhost:3000/health',r=>process.exit(r.statusCode===200?0:1))"
CMD ["node", "dist/server.js"]

The decisions worth noticing, because each addresses something from earlier in this lesson:

Digest-pinned base, in both stages, so the build is reproducible.

Dependencies before source, so a code change does not reinstall.

npm cache clean in the same RUN as the install — a separate layer would not shrink anything.

Numeric USER, which is what runAsNonRoot checks.

Only dist copied from the build stage, not the whole /app.

A health check, which is what makes Compose’s service_healthy and Kubernetes readiness work.

Putting these in path-specific instructions scoped to **/Dockerfile* means they apply without being restated:

- Multi-stage builds. The runtime stage contains no build toolchain.
- Base images pinned by digest.
- Dependencies copied and installed before application source.
- Run as a numeric non-root UID.
- Include a HEALTHCHECK.
- Never pass secrets as build arguments.

The other half of container work, where an agent that can run commands helps most.

Getting inside. docker exec -it CONTAINER sh is the reflex, and it fails on minimal images that contain no shell — a distroless or scratch-based image has no sh to exec into. That is a security feature working as intended, and the answer is to debug from outside rather than to add a shell.

Debugging from outside. docker inspect, docker logs, docker top, docker stats, and copying files out with docker cp all work without a shell in the image. An agent that knows the constraint proposes these; one that does not proposes exec and gets confused when it fails.

Networking. “The container cannot reach the database” is usually one of three things: the wrong network, the wrong hostname — service names resolve inside a Compose network and not from the host — or the service inside the container not being ready yet.

Terminal window
{/* What networks is this container on, and what is its address? */}
docker inspect CONTAINER --format '{{json .NetworkSettings.Networks}}' | jq

Resource limits. A container killed at exactly its memory limit is not an application bug. docker stats during the failure, or the OOMKilled flag afterwards, distinguishes the two — and this is the diagnosis people most often get wrong by looking for a memory leak that is not there.

The recurring value here is the same as everywhere in this cluster: the agent knows which command answers the question, and running four diagnostic commands and correlating their output is mechanical work it does quickly.

COPY . . without a .dockerignore. Ships .git, .env and node_modules.

Dependencies copied after source. Defeats layer caching on every source change.

Running as root because the example did. Two lines to fix, and rarely added unprompted.

Secrets in build arguments. Recoverable from image metadata.

Deleting files in a later layer to save space. Layers are additive; it does not work.

docker system prune from an agent session. One flag from removing your volumes.

Trusting a Dockerfile review as a vulnerability scan. Use a scanner.

Assuming depends_on waits for readiness. It waits for the container to start, which is not the same as the service inside it accepting connections.

Reaching for docker exec on a minimal image. No shell is a security property, not a problem to fix by adding one.

Diagnosing a memory leak that is an OOM kill. Check OOMKilled and the limit before profiling.

Tags, digests and what an agent should reference

Section titled “Tags, digests and what an agent should reference”

A detail that matters more than it looks, and one where generated commands default to the convenient form.

A tag is a mutable pointer. myapp:v1.2.3 can be repointed to a different image, and every system referencing that tag then gets something else with no change to the reference.

A digest is the content. myapp@sha256:abc… cannot be repointed.

Generated Docker commands almost always use tags, because that is what documentation examples use and what humans type. For local development that is fine. For anything that matters — a deployment manifest, a signature, an attestation, a promotion between environments — the digest is the correct reference.

Terminal window
{/* Get the digest of an image without pulling it */}
docker buildx imagetools inspect ghcr.io/YOUR_ORG/YOUR_IMAGE:TAG --format '{{.Manifest.Digest}}'

The instruction worth adding where this matters:

When referencing images in deployment manifests or signing commands, use the digest rather than the tag.

Signing container images covers the full argument, including why signing a tag produces a signature that describes whatever the tag pointed at when you signed rather than what it points at now.

A short assessment, because container documentation is good and the temptation is to read it instead.

It wins on: long build output, multi-command diagnosis, and anything where the answer depends on the state of your machine — which images exist, which containers are running, what the network looks like.

It draws with documentation on: Dockerfile syntax you could look up. Both are fast; the agent is faster if you are already in the terminal.

It loses to: the official reference for anything where exactness matters and the answer is stable. Base image tags, BuildKit syntax, Compose schema versions. A model may give you a version-appropriate answer or a version-confused one, and the documentation will not.

It loses badly to: a scanner, for vulnerabilities. A Dockerfile review finds patterns; Trivy or Grype find CVEs with versions and severities. Those are not comparable activities, and treating the first as the second is the mistake this cluster warns about everywhere.

The productive split, as ever: let it read the long output and correlate the state; check the exact syntax against the reference; run the scanner for security.

A Dockerfile is a build script whose mistakes are invisible until the image is large, insecure or missing something. An agent reads it the way a careful reviewer would — and the commands that clean up afterwards are the ones to keep out of its hands.

  • Build debugging is the highest-value use; the error is buried and the output is long
  • Dependencies before source is the layer-caching rule generated Dockerfiles usually miss
  • .dockerignore is separate from .gitignore, and COPY . . copies the build context
  • Copying .git ships every credential ever committed
  • A numeric USER is what Kubernetes runAsNonRoot checks
  • Layers are additive: deleting a file later does not shrink the image
  • Build arguments are visible in docker history; use BuildKit secret mounts
  • depends_on waits for container start, not service readiness
  • docker system prune and volume removal are commands to type yourself

Use a disposable project with a Dockerfile.

  1. Ask for a Dockerfile for your application with no further qualification. Predict: does it run as root? Is the base image pinned?

  2. Ask again specifying non-root, a pinned base and multi-stage. Compare.

  3. Build both and compare sizes with docker images.

  4. Run docker history --no-trunc on the larger one and ask which layers are largest and why.

  5. Add a .env to the directory with no .dockerignore, build, and check whether it is in the image. Predict: does .gitignore protect you?

  6. Pass a fake secret with --build-arg and look for it in docker history. Predict: is it visible?

  7. Break the build deliberately and ask the agent to diagnose from the output. Predict: does it find the cause faster than you scrolling?

  8. Remove the images and the project.

AI-assisted engineering learning pathEleven lessons on getting value from Copilot and agents without giving up review discipline.