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.
Debugging a build
Section titled “Debugging a build”The highest-value use. Build output is long, the actual error is buried, and the surrounding context matters.
docker build -t myapp:dev . 2>&1 | tail -50This 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.
Writing Dockerfiles
Section titled “Writing Dockerfiles”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 ciCOPY 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 appuserUSER 10001A 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_HEREThe 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.
.git.env.env.*node_modules*.logMulti-stage builds
Section titled “Multi-stage builds”Where an agent helps with something genuinely fiddly.
FROM node:22-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:22-alpine AS runtimeWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY --from=build /app/dist ./distRUN useradd --system --uid 10001 appuserUSER 10001CMD ["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.
Image size
Section titled “Image size”A good analysis task, because the data is available and reading it is tedious.
docker history myapp:dev --no-truncWhat 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.
Reading container logs
Section titled “Reading container logs”The operational half, and where an agent saves the most time during an incident.
{/* Recent logs, with timestamps */}docker logs --tail 200 --timestamps CONTAINER{/* 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.
Container security
Section titled “Container security”Pattern-shaped checks that an agent applies consistently:
- Running as root
- Unpinned or
:latestbase images - Secrets in
ENVor build arguments — visible indocker 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.
Compose
Section titled “Compose”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.
Working in CI
Section titled “Working in CI”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.
What not to delegate
Section titled “What not to delegate”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.
{/* See what would go, without removing it */}docker system dfdocker image ls -f dangling=truedocker 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.
A hardened starting point
Section titled “A hardened starting point”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 buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run build
{/* Runtime stage — production dependencies and build output only */}FROM node:22-alpine@sha256:YOUR_DIGEST_HERE AS runtimeWORKDIR /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 --forceCOPY --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.Debugging a running container
Section titled “Debugging a running container”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.
{/* What networks is this container on, and what is its address? */}docker inspect CONTAINER --format '{{json .NetworkSettings.Networks}}' | jqResource 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.
Common mistakes
Section titled “Common mistakes”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.
{/* 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.
Where the CLI beats the browser
Section titled “Where the CLI beats the browser”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.
Mental model
Section titled “Mental model”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.
What you learned
Section titled “What you learned”- 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
.dockerignoreis separate from.gitignore, andCOPY . .copies the build context- Copying
.gitships every credential ever committed - A numeric
USERis what KubernetesrunAsNonRootchecks - Layers are additive: deleting a file later does not shrink the image
- Build arguments are visible in
docker history; use BuildKit secret mounts depends_onwaits for container start, not service readinessdocker system pruneand volume removal are commands to type yourself
Exercise
Section titled “Exercise”Use a disposable project with a Dockerfile.
-
Ask for a Dockerfile for your application with no further qualification. Predict: does it run as root? Is the base image pinned?
-
Ask again specifying non-root, a pinned base and multi-stage. Compare.
-
Build both and compare sizes with
docker images. -
Run
docker history --no-truncon the larger one and ask which layers are largest and why. -
Add a
.envto the directory with no.dockerignore, build, and check whether it is in the image. Predict: does.gitignoreprotect you? -
Pass a fake secret with
--build-argand look for it indocker history. Predict: is it visible? -
Break the build deliberately and ask the agent to diagnose from the output. Predict: does it find the cause faster than you scrolling?
-
Remove the images and the project.