GitHub Copilot for Docker Compose

GitHub Copilot for DevOps & InfrastructureAcademy lesson 41Cluster 4 · Lesson 3 of 13Intermediate12 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for Docker ComposeGitHub Copilot for DevOps & Infrastructure3Intermediate/github-copilot/devops/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 ignored

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

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
  - cache

That 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_healthy

and 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

examples/cluster-4/docker-compose

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

Copilot promptGenerate the environmentCopilot Chat

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

The 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 refused

Refused, 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

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

Accepting a generated compose.yaml
  1. Run docker compose config -qConfirms the model resolves. Exit 0 is necessary and not sufficient.
  2. 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.
  3. Check every ports entryHuman judgementWhich interface, and does this service need a host port at all?
  4. Check every depends_onHuman judgementservice_healthy or service_started? And is there a healthcheck for it to wait on?
  5. Grep for anything that looks like a secretHuman judgementenvironment blocks, command arguments, and the file itself.
  6. Check the image tagsHuman judgementExact tags, including on services this file builds.
  7. 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_healthy explicitly. 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 --wait in scripts and CI so an ordering problem is a failure rather than a race.
  • Put the recurring rules in .github/instructions/compose.instructions.md rather than in every review.

Common mistakes

  • Trusting exit code 0 from docker compose config.
  • Leaving the short depends_on list form and debugging the resulting race as an application bug.
  • Publishing a database port “just for now”.
  • Writing a health check that calls curl in an image without curl.
  • Running docker compose down -v on 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.

Primary sources