Backend / Docker / 05_docker_compose.md

Docker Compose

Updated 4 interview angles 5 min read source
On this page9
  1. The tool and the file today
  2. Start order is not readiness
  3. Override files merge, they do not replace
  4. Postgres 18 moved its data directory
  5. Secrets and configuration
  6. Where it breaks in production
  7. The trade not taken
  8. Related
  9. Interview angle

Docker Compose

Article.

Compose defines a multi-container application in one file (compose.yaml): services, networks, volumes, and how they start. It runs on one host: the right tool for local development, integration tests in CI, and small single-machine deployments, not an orchestrator for many machines.

The API crash-loops because Postgres is not ready yet. Fix it in Compose. Everything below was run with Docker Compose v5.5.1 and postgres:18.

The tool and the file today

bash
docker compose up -d          # the plugin; `docker-compose` (V1) is end of life

The CLI went from v2 straight to v5, skipping 3 and 4 to avoid confusion with the old file-format versions. The file itself has no version any more:

text
WARN: the attribute `version` is obsolete, it will be ignored,
      please remove it to avoid potential confusion

Delete version: "3.9"; the Compose Specification replaced the 2.x/3.x formats, and the canonical file name is compose.yaml.

Start order is not readiness

yaml
services:
  db:
    image: postgres:18
  app:
    image: myapp
    depends_on: [db]          # waits for the db container to START
text
three runs, the app connecting as soon as it starts:
app: OperationalError: connection failed: ... connection refused     (3 of 3)

Short-form depends_on only orders container start. Postgres takes a few seconds after starting before it accepts connections, and the app tried during that window every time.

Healthchecks and conditions

Readiness needs a healthcheck and a condition:

yaml
services:
  db:
    image: postgres:18
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 1s
      retries: 30
  migrate:
    image: myapp
    command: ["alembic", "upgrade", "head"]
    depends_on:
      db: {condition: service_healthy}
  app:
    image: myapp
    depends_on:
      db: {condition: service_healthy}
      migrate: {condition: service_completed_successfully}
text
migrations applied
app: connected after 0.0s
db running Up 19 seconds (healthy)

service_completed_successfully turns a one-shot container into a gate: migrations finish before the app starts, and a failed migration stops the app from starting at all.

And an hour later?

The conditions apply at startup only. When Postgres restarts later, Compose does not stop the app or wait; the app sees broken connections. So the app still needs its own resilience: connection retries with backoff, a pool that validates connections (pool_pre_ping), and a restart policy (restart: unless-stopped). The healthcheck fixes the race at startup; only the application fixes it at run time.

Override files merge, they do not replace

yaml
# compose.yaml                      # compose.override.yaml
services:                           services:
  app:                                app:
    ports: ["8000:8000"]                ports: ["8001:8000"]
text
docker compose config      ->  published: "8000"  and  published: "8001"
with  ports: !override ["8001:8000"]   ->  published: "8001"

compose.override.yaml is merged automatically on top of compose.yaml. Single values are replaced, but sequences such as ports are appended, so both ports were published. The !override tag replaces the list and !reset removes it. docker compose config prints the merged model: check it before assuming what runs.

Postgres 18 moved its data directory

text
docker run -v pgdata:/var/lib/postgresql/data postgres:18
Error: in 18+, these Docker images are configured to store database data in a
       format which is compatible with "pg_ctlcluster" ...

From 18 the image keeps data under /var/lib/postgresql/18/docker and declares the volume at /var/lib/postgresql; mounting the old path stops it from starting. Mount /var/lib/postgresql, which also makes major-version upgrades with pg_upgrade --link possible inside one volume.

yaml
  db:
    image: postgres:18
    volumes: ["pgdata:/var/lib/postgresql"]
volumes:
  pgdata:

Secrets and configuration

yaml
services:
  app:
    env_file: .env                       # not committed
    secrets: [db_password]               # mounted at /run/secrets/db_password
secrets:
  db_password:
    file: ./secrets/db_password.txt

Plaintext passwords inline in compose.yaml end up in git. Variables can come from .env (interpolated as ${VAR}) and secrets from files mounted under /run/secrets/, which keeps them out of docker inspect output and out of the image.

Where it breaks in production

Compose as the production orchestrator

One host, no rescheduling when the machine dies, no rolling updates across machines. For a small internal tool that is fine and simpler than Kubernetes; for anything that must survive a host failure, it is not. Know which one you are running.

Scaling a service with a published port

docker compose up --scale web=3 with ports: ["5000:5000"] fails with “port is already allocated”: three containers cannot bind one host port. Put a reverse proxy in front, or publish a range.

Data in an anonymous volume

A database without a named volume gets an anonymous one; docker compose down -v, or a project rename, and the data is gone. Name every volume that holds state.

Bind mounts in CI and production

.:/app is right for development (live code reload) and wrong anywhere else: the container runs whatever is on the host disk, not the image you tested. Use the image’s own files outside development.

The trade not taken

Kubernetes, or a managed service such as ECS, is the multi-machine answer: scheduling, self-healing, rolling deploys, service discovery across nodes. It costs a control plane and a much larger configuration surface.

Compose stays the right tool for development and CI even in Kubernetes shops, because the whole stack starts with one command on a laptop; the mistake is only using it where the guarantees of an orchestrator are needed (Scaling containers).

Interview angle 4

  • “The API crash-loops because Postgres is not ready. Fix it.” - a healthcheck on the database (pg_isready) and depends_on with condition: service_healthy. Plain depends_on only orders start: the app failed 3 of 3 times without it.
    • Follow-up: “Migrations must finish first.” - a one-shot migrate service and condition: service_completed_successfully on the app.
    • Follow-up: “Postgres restarts an hour later.” - Compose does nothing; the app needs retries, connection validation and a restart policy.
  • “The override should replace a port, but both are published. Why?” - lists are appended when files merge. docker compose config shows the merged result; !override replaces the list.
  • “Why ‘version is obsolete’?” - the Compose Specification dropped the file version; the CLI’s own version (v5) is unrelated. Delete the key.
  • “Compose or Kubernetes?” - Compose for one host: development, CI, small deployments. Kubernetes when the service must survive machine failures and deploy across nodes.