GitHub Copilot for Docker Compose
Compose is the format where the gap between “it parses” and “it is correct” is widest, because the parser is generous and the file is describing runtime behaviour it cannot check.
Here is the measurement that frames the lesson. A plausible first-draft
compose.yaml for an API, a PostgreSQL database and a Redis cache — the kind of
thing you get from “give me a Compose file for these three services” — was run
through docker compose config:
exit 0
warning: the attribute `version` is obsolete, it will be ignoredOne warning, about a key that does nothing. The file also published PostgreSQL
on every interface of the host, hard-coded the database password, used :latest
for both backing services, and started the API before the database could accept
connections. None of that is a parse error, because none of it is a parse
question.
Key takeaways
docker compose configvalidates the model, not the design. Read its output, do not just check its exit code.depends_ondefaults tocondition: service_started, which means “the container process exists”. Almost always what you want iscondition: service_healthy."5432:5432"publishes on every interface."127.0.0.1:5432:5432"does not, and a backing service should publish nothing at all.${VAR:?message}turns a missing secret into a parse-time error with your own wording.${VAR}turns it into an empty string.- The
version:key has been obsolete since Compose v2. Its presence in a suggestion is a reliable signal the rest of the file is drawn from old examples.
What Compose is for, and what it is not
Worth stating because it changes what “correct” means. Compose describes a local development environment: several containers on one host, on one network, started together. It is not an orchestrator, it does not schedule across machines, and a Compose file is not a deployment artefact.
That has a direct consequence for review. Configuration that would be a serious problem in production — a bind mount of the source tree, a container running with elevated privileges, a service reachable from the host — may be entirely correct here, because the whole environment is disposable and lives on one laptop. The risks that survive the local/production distinction are the ones this lesson concentrates on: secrets committed to a file, ports on the wrong interface, and images that are not pinned.
Copilot is genuinely good at Compose. The format is small, extremely well-represented in public code, and largely declarative. The failures are concentrated in three places: startup ordering, network exposure, and secret handling.
The dependency problem
This is the defect that costs the most debugging time, because the symptom looks like an application bug.
depends_on:
- db
- cacheThat is the short form, and it means condition: service_started — Compose
starts the containers in order and waits for the container to exist. PostgreSQL
takes several seconds after that to initialise its data directory and accept
connections. So the API starts, tries to connect, fails, and either crashes or
enters a restart loop.
The developer’s experience is that the stack “sometimes works”, which is the worst kind of bug. On a warm machine with a pre-initialised volume, the database is ready fast enough and everything is fine. On a cold checkout, it is not.
The fix requires two things, and suggestions usually supply neither:
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthyand a healthcheck on db and cache for that condition to wait on. Without
the health check there is nothing to be healthy, and Compose will tell you so.
Practical project: API, database and cache
Practical example
A three-service development environment
An API that waits for PostgreSQL and Redis to be healthy, with neither backing service reachable from the host and no secret in the file.
- Status
- Tested implementation
- Runtime
- Docker Compose v5.5.0, Docker Engine 29.7.2, postgres:17.7-alpine, redis:8.4-alpine
- Command
docker compose config -q && docker compose up -d --wait && curl -s localhost:8000/healthz && docker compose down- Result
- config exit 0; db and cache reached Healthy before api started, then api reached Healthy; /healthz returned 200 {"status":"ok","dependencies":{"database":"db:5432/app","cache":"redis://cache:6379/0"}}; a TCP connect to 127.0.0.1:5432 was refused; down was clean.
- Run on
- August 21, 2026
Files
copilot-compose-demo/ ├── compose.yaml ├── compose.draft.yaml the first suggestion, kept for comparison ├── .env.example ├── .gitignore contains: .env ├── app/ │ ├── Dockerfile │ ├── main.py │ └── requirements.txt └── .github/ ├── copilot-instructions.md └── instructions/ └── compose.instructions.md
The prompt
Create a compose.yaml for local development with three services: a FastAPI API built from ./app, PostgreSQL, and Redis.
Requirements:
- No
version:key. - Pin every image to an exact tag, including the one this file builds.
- Only the API publishes a host port, bound to 127.0.0.1. The database and cache must not appear in a ports block at all.
- Every service has a healthcheck, and depends_on uses condition: service_healthy.
- The database password comes from the environment using a required-variable interpolation, so a missing value is a parse error.
- A named volume for the database data.
- Memory and CPU limits on the API.
The file
name: copilot-compose-demo
services:
api:
build:
context: ./app
image: copilot-compose-demo/api:local
restart: unless-stopped
# Bound to loopback, not 0.0.0.0. "8000:8000" publishes on every
# interface, which on a laptop on a shared network is a public service.
ports:
- "127.0.0.1:8000:8000"
environment:
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/app
REDIS_URL: redis://cache:6379/0
APP_VERSION: ${APP_VERSION:-0.1.0}
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).status == 200 else 1)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
db:
image: postgres:17.7-alpine
restart: unless-stopped
# Deliberately no `ports:`. Other services reach it over the Compose
# network by name. Publishing 5432 puts a database on the host interface.
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 5s
retries: 10
start_period: 5s
cache:
image: redis:8.4-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
db-data:The health check on api uses the Python interpreter that is already in the
image rather than curl, which the slim base does not have. Generated health
checks reach for curl constantly and then fail with executable file not found, which reads like a broken image rather than a missing tool.
The required-variable behaviour, observed
With POSTGRES_PASSWORD unset:
$ docker compose config -q
error while interpolating services.api.environment.DATABASE_URL:
required variable POSTGRES_PASSWORD is missing a value:
set POSTGRES_PASSWORD in .env
error while interpolating services.db.environment.POSTGRES_PASSWORD:
required variable POSTGRES_PASSWORD is missing a value:
set POSTGRES_PASSWORD in .envThe message after :? is yours. That is worth using — a parse error naming the
file the operator should edit is considerably more useful than an empty string
that produces a connection failure ten seconds later.
Startup, observed
$ docker compose up -d --wait
Container copilot-compose-demo-db-1 Healthy
Container copilot-compose-demo-cache-1 Healthy
Container copilot-compose-demo-api-1 Starting
Container copilot-compose-demo-api-1 Started
Container copilot-compose-demo-api-1 Healthy
$ docker compose ps
SERVICE STATUS
api Up 13 seconds (healthy)
cache Up 18 seconds (healthy)
db Up 18 seconds (healthy)The ordering in that output is the point: db and cache reach Healthy before
api starts.
Is the database exposed?
$ exec 3<>/dev/tcp/127.0.0.1/5432
bash: connect: Connection refusedRefused, because db has no ports: block. If the draft’s "5432:5432" had
survived, that connect would have succeeded — and so would one from any other
machine on the network.
Validation
Validate before deploying
Docker Compose: the local validation sequence
| Step | Command | Executed here |
|---|---|---|
| Parse and interpolate | docker compose config -q | PASSExit 0. With POSTGRES_PASSWORD unset it failed with the message the file supplies |
| Inspect the resolved model | docker compose config | PASSdocker compose up -d --wait brought db and cache to Healthy before api started; a TCP connect to 127.0.0.1:5432 was refused |
docker compose config does more than check syntax. It resolves variable
interpolation, merges override files, applies profiles, and prints the model
Compose will actually use. Reading that output — rather than the source file — is
how you find a variable that resolved to nothing or an override that did not
apply.
Networks, volumes and the things you get by default
Compose creates a default network and attaches every service to it, which is why
db is reachable at the hostname db with no configuration. Generated files
often add an explicit networks: block that recreates exactly this, which is
harmless noise. Custom networks are worth it when you genuinely want segmentation
— a database reachable by the API and not by a worker — and a suggestion will
not propose that unless asked.
Volumes divide into three kinds and the distinction matters for review. A named
volume is managed by Docker and survives docker compose down; it is the right
choice for database data. A bind mount maps a host directory into the
container and is the right choice for source code in development — and a
liability if a suggestion mounts something broader than intended, such as the
whole home directory or the Docker socket. An anonymous volume is what you
get when a path is declared with no source, and it accumulates silently.
Profiles, overrides and multiple files
Two mechanisms exist for varying a stack, and suggestions tend to conflate them.
Profiles attach services to named groups; a service with a profiles: key
does not start unless its profile is selected. This is the clean way to keep an
optional tool — a database admin UI, a mail catcher, a seeding job — in the same
file without running it every time.
Override files merge on top of the base. compose.override.yaml is picked up
automatically, and additional files can be supplied with repeated -f flags,
merged left to right. This is the mechanism for genuinely different environments.
The trap in both is that merging is not intuitive: lists are replaced, maps are
merged, and it is easy to believe an override applied when it did not. This is
the second reason to read docker compose config rather than the source — it
prints the result of the merge.
Debugging a stack that will not come up
Compose failures have a small number of causes and a reliable order to check them in. This sequence is worth having in your instructions file, because it is also the sequence you want an agent to follow rather than guessing.
docker compose ps first. It distinguishes the three states that look alike
from the outside: a container that exited, a container restarting in a loop, and
a container running but unhealthy. Each has a different cause and the fix for one
is never the fix for another.
Then docker compose logs <service>. Not the whole stack — one service, the
one that is unhealthy. --tail 50 -f for a restart loop, because the interesting
output is the last thing before each exit and it scrolls past otherwise.
For a service that is Up but never healthy, run the health check by hand.
docker compose exec api python -c "…" with the exact command from the file. A
surprising proportion of health-check failures are the command being wrong rather
than the service being unwell — a missing binary, a wrong port, a path that
returns 404.
For a connection failure between services, check the name and the port.
Services reach each other by service name on the container port, not the
published one. postgresql://…@db:5432 is right even if the host mapping says
something else, and generated configuration sometimes uses localhost, which
inside a container means the container itself.
For anything involving variables, go back to docker compose config. An
empty interpolation is invisible in the source file and obvious in the resolved
output.
This is one of the better uses of chat during a failure: paste
docker compose ps, the failing service’s logs, and the compose.yaml, and ask
which of those four categories it is. The inputs are precise, and narrowing the
category is most of the work.
From Compose to production
Compose files get translated to Kubernetes manifests constantly, and it is a task
Copilot does well because the correspondence is mechanical: a service becomes a
Deployment plus a Service, environment: becomes a ConfigMap or Secret
reference, a published port becomes a Service port, a named volume becomes a
PersistentVolumeClaim.
What does not translate, and what to check in the result:
Health checks become two probes, not one. A Compose healthcheck maps most
closely to a readiness probe. Liveness is a different question — “should this be
restarted” rather than “should this receive traffic” — and a generated conversion
that duplicates the same check into both creates a service that restarts itself
whenever a dependency is slow.
depends_on has no equivalent. Kubernetes does not order Deployments.
Ordering is expressed through readiness and retries, and a translation that
silently drops depends_on has dropped a real requirement rather than an
unsupported one.
Resource limits are advisory locally and enforced in the cluster. The
deploy.resources.limits block that kept a laptop responsive becomes the
difference between a scheduled Pod and a pending one.
Restart policy semantics differ. restart: unless-stopped has no direct
Kubernetes counterpart; a Deployment always restarts its Pods.
The translation is a good first draft and a poor final answer. Treat it as structure to review rather than as output to accept, and read the Kubernetes lesson before deploying any of it.
Compose-specific risks
service_started where service_healthy belongs. Covered above.
Ports on 0.0.0.0. The short "8000:8000" form binds every interface.
Explicit 127.0.0.1: is one prefix and it is the difference between a local
service and one your coffee shop can reach.
:latest on backing services. A postgres:latest that crosses a major
version will refuse to start against an existing data directory, with an error
about incompatible data files. This is a genuinely nasty way to lose an
afternoon.
Secrets in environment:. The values are visible in docker inspect, in
docker compose config output, and in the file itself if it is committed.
The obsolete version: key. Harmless, and a reliable indicator that the
suggestion came from pre-v2 examples — so read the rest more carefully.
No resource limits. A runaway container on a laptop takes the laptop with
it. deploy.resources.limits works in Compose without Swarm and is worth
setting.
restart: always on a failing service. Combined with no health check, it
produces an infinite restart loop that looks like the stack is running.
Destructive commands
The -v flag is one character and it is the difference between stopping a stack
and deleting its data. Generated cleanup instructions include it routinely,
because it is the version that leaves the machine tidy.
Review workflow
- Run docker compose config -qConfirms the model resolves. Exit 0 is necessary and not sufficient.
- Read the full docker compose config outputHuman judgementNot the source file — the merged, interpolated result. This is where a missing variable or a non-applied override shows up.
- Check every ports entryHuman judgementWhich interface, and does this service need a host port at all?
- Check every depends_onHuman judgementservice_healthy or service_started? And is there a healthcheck for it to wait on?
- Grep for anything that looks like a secretHuman judgementenvironment blocks, command arguments, and the file itself.
- Check the image tagsHuman judgementExact tags, including on services this file builds.
- Run docker compose up -d --waitThe --wait flag makes Compose block until every service is healthy, so an ordering bug fails here rather than intermittently.
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 health checks and
condition: service_healthyexplicitly. Nothing else in this lesson pays back as reliably. - Bind published ports to
127.0.0.1, and publish nothing for backing services. - Use
${VAR:?message}for required values and write a useful message. - Pin every image to an exact tag; a major-version jump on a database is not a recoverable mistake.
- Use
--waitin scripts and CI so an ordering problem is a failure rather than a race. - Put the recurring rules in
.github/instructions/compose.instructions.mdrather than in every review.
Common mistakes
- Trusting exit code 0 from
docker compose config. - Leaving the short
depends_onlist form and debugging the resulting race as an application bug. - Publishing a database port “just for now”.
- Writing a health check that calls
curlin an image withoutcurl. - Running
docker compose down -von a stack whose volume held something you wanted.
Where to go next
GitHub Copilot for Docker covers the image this file builds. GitHub Copilot for Kubernetes is where the same three services become manifests — and where the health checks you wrote here become readiness and liveness probes with rather more consequences.
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.