Skip to content

Running automation tooling with Docker Compose

Automation tooling rarely runs alone. A source-of-truth database wants a cache and a worker beside it; a lab of scripts wants a consistent Python image. This article shows how to read and write a Docker Compose file so that a whole stack — its containers, their networking, and their data — is captured in one version-controlled file and comes up with a single command.

Compose is infrastructure as code for the tooling layer. A single docker run is fine for one container, but the moment several containers must run together, you want that topology written down rather than reconstructed by hand each time. docker compose up then reproduces the same environment on any host that has Docker.

The anatomy of a Compose file

A Compose file is YAML with a few top-level keys. Three describe the stack:

  • services — the containers that make up the application. Each service is one container (or a scalable set of identical ones).
  • networks — the virtual networks that connect services. Containers on the same network reach each other by service name, resolved through Docker's embedded DNS.
  • volumes — named, persistent storage that outlives any single container, plus bind mounts that map a host path into a container.

You will still see links in older files; it is the deprecated predecessor of user-defined networks. Recognize it, but reach for networks in anything you write today.

Within a service, a handful of keys carry most of the meaning:

Key What it does
image / build Run a prebuilt image, or build one locally from a Dockerfile
ports Map host:container ports to expose a service to the host
volumes Attach named volumes or bind mounts
environment / env_file Set environment variables (configuration and secrets)
depends_on Control start order relative to other services
networks Which networks this service joins
healthcheck How Compose decides a service is actually ready

A worked example

The file below defines a small NetBox-style source-of-truth stack: a web application, a Postgres database, and a Redis cache, wired across two networks with a named volume for database persistence.

services:
  web:
    image: netboxcommunity/netbox:latest
    ports:
      - "8080:8080"              # host 8080 -> container 8080
    environment:
      DB_HOST: database          # reach Postgres by its service name
      REDIS_HOST: cache
    depends_on:
      database:
        condition: service_healthy   # wait until Postgres is ready, not just started
      cache:
        condition: service_started
    networks:
      - frontend
      - backend

  database:
    image: postgres:16
    environment:
      POSTGRES_DB: netbox
      POSTGRES_USER: netbox
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in the environment}
    volumes:
      - pgdata:/var/lib/postgresql/data   # named volume persists the data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U netbox -d netbox"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    networks:
      - backend

  cache:
    image: redis:7
    networks:
      - backend

networks:
  frontend:
  backend:

volumes:
  pgdata:

Read it top-down:

  • The web service publishes port 8080 to the host and joins both networks. It reaches database and cache by name because they share the backend network.
  • Only web is on the frontend network and exposes a port, so the database and cache are not reachable from outside the stack — a sensible isolation pattern that keeps the data tier private.
  • The named volume pgdata keeps the database's files even if the container is recreated. Without it, a docker compose down followed by up would start from an empty database.

Networking: reach services by name, not by IP

Because web, database, and cache share a user-defined network, the application connects to database:5432, never a hard-coded IP address. Docker's embedded DNS resolves the service name to whatever address the container currently has, so the stack keeps working when containers are recreated and addresses change. Splitting the stack into frontend and backend networks then controls who can talk to whom: only services placed on a network can reach the others on it.

Start order is not readiness

This is the pitfall that causes intermittent, hard-to-reproduce failures. Plain depends_on guarantees only that a dependency's container has started — not that the service inside it is accepting connections. A Postgres container reports "started" long before the database has finished initializing, so an app that connects immediately can race it and fail.

There are two robust fixes, and the example above uses both ideas:

  1. Give the dependency a healthcheck and wait on it. The long-form depends_on with condition: service_healthy makes Compose hold web back until Postgres's pg_isready check passes. The valid conditions are service_started (the default), service_healthy, and service_completed_successfully.
  2. Make the client retry. Even with a health check, treat a missing dependency as a transient error and reconnect with backoff. Networks and dependencies can also restart after startup, so resilient clients matter beyond the first boot.

A health check is only as good as its test command: it must actually probe readiness (a real query, not just "is the process alive") and it must eventually turn healthy, or the dependent service will wait forever.

Keep secrets out of the file

A committed Compose file is visible to everyone with repository access, so the literal POSTGRES_PASSWORD: changeme you often see in tutorials is a habit worth breaking. Reference values from the environment instead — ${DB_PASSWORD:?...} above fails fast with a clear message if the variable is unset — and keep the real values in a local .env file that is git-ignored, or in a secrets manager. For production, Compose also supports a top-level secrets mechanism that mounts sensitive values as files rather than environment variables.

A few modern conventions

Compose has changed enough that older examples can mislead:

  • Use the docker compose (v2) subcommand, not the standalone docker-compose binary. The Python-based v1 tool was removed from Docker's official images and the GitHub Actions runners in 2025.
  • Drop the top-level version: key. It has been ignored since Compose v2 and now only produces an "obsolete" warning; Compose always validates against the latest Compose Specification.
  • Prefer the filename compose.yaml. It is the canonical name in the specification; docker-compose.yml still works for backward compatibility.

The everyday commands are few: docker compose up -d starts the stack in the background, docker compose ps lists it, docker compose logs -f web follows one service's output, and docker compose down stops it. Add -v to down only when you want to delete the named volumes — that is what discards the database.

Key takeaways

  • Compose captures a multi-container stack — containers, networking, and data — in one version-controlled YAML file, and docker compose up reproduces it anywhere.
  • services are the containers; networks connect them (same network → reach each other by service name via DNS); volumes persist data; links is legacy.
  • ports map host:container, and only published ports are reachable from the host — keep data-tier services off the public network.
  • A named volume survives container recreation; without one, recreating a container loses its data.
  • depends_on controls start order, not readiness. Add a healthcheck with condition: service_healthy, and make clients retry.
  • Keep secrets in the environment or a secrets manager, not in the committed file; drop the obsolete version: key and use the docker compose v2 command.