What a multi-service compose.yaml is and who it is for

A Compose project is one compose.yaml that describes every process your application needs to run locally, plus the plumbing between them. For a typical backend that means three compose services: the API you are actively editing, a Postgres database, and Redis for caching or queues. Compose treats the file as a single unit. One command creates and starts the whole stack, and the same tool stops it, rebuilds it, shows status, streams logs, and runs one-off commands against a service (Docker Compose documentation).

Networking is the first thing that changes. Compose places all services on a default project network, named after the project (by default, the directory name), and registers each service name in DNS. The API connects to postgres:5432 and redis:6379, not localhost. Inside a container, localhost is the container itself, so connection strings copied from a laptop-native setup fail.

Compose fits when several engineers need the same dependent services running together, when onboarding should take one command instead of a wiki page of install steps, and when "works on my machine" bugs come from version mismatches in local databases.

The base file in the example below pins every image to a specific tag, such as a major and minor Postgres version, and never uses latest. A floating tag resolves to whatever was newest when each developer last pulled. Two engineers can end up on different database versions while running the same file.

# compose.yaml - base file for local development
# The project name defaults to the directory name. Every service joins
# the default project network and can be reached by its service name.
services:
  api:
    build: .                      # the service you are actively editing
    ports:
      - "8000:8000"               # host:container, so you can call the API from your laptop
    environment:
      # Use service names, not localhost: inside this container,
      # localhost is the api container itself.
      DATABASE_URL: postgres://app:devpassword@postgres:5432/app
      REDIS_URL: redis://redis:6379/0
    depends_on:
      - postgres
      - redis

  postgres:
    # Pin major.minor so every developer runs the same database version.
    # A floating tag like postgres:latest changes depending on when each person last pulled.
    image: postgres:16.4
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpassword   # local-only placeholder, not a real secret
      POSTGRES_DB: app

  redis:
    image: redis:7.4                    # pinned for the same reason

A minimal compose.yaml that defines an API, Postgres and Redis on the default project network, pins image tags and connects services by their names instead of localhost.

How startup ordering works: depends_on, healthchecks and readiness

A plain depends_on list only controls the order in which containers are created and started. Compose starts postgres before api, but it does not wait for Postgres to accept connections. Postgres can take several seconds to initialise a fresh data directory, so an API that connects at boot will often fail on the first docker compose up after a volume reset.

To make Compose wait, use the long form of depends_on with condition: service_healthy and give the dependency a healthcheck. The check should test readiness, not just whether the process is running. For Postgres, pg_isready confirms the server is accepting connections. For Redis, redis-cli ping returning PONG confirms the server is answering commands.

Each healthcheck takes these settings:

  • test: the command to run.
  • interval: how often to run it.
  • timeout: how long a single check may take.
  • retries: how many consecutive failures mark the service unhealthy.
  • start_period: a grace window during which failures don't count, which covers first-run initialisation.

Run docker compose ps to confirm the setup works. The status column shows (starting), (healthy) or (unhealthy).

This approach has trade-offs:

  • Slower startup. Dependent services wait for at least one passing check, so a long interval adds latency to every up.
  • Flaky timeouts. Values tuned on a fast laptop can fail on slower machines, under emulation or in CI. Set start_period generously.
  • Startup only. Conditions apply only at startup. If Postgres restarts mid-session, Compose won't pause the API. Application code still needs connection retries with backoff.
# compose.yaml: the api starts only after postgres and redis report healthy
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: example-dev-password  # local placeholder, not a real secret
      POSTGRES_DB: app_dev
    healthcheck:
      # pg_isready checks that the server accepts connections, not just that the process is running
      test: ["CMD-SHELL", "pg_isready -U app -d app_dev"]
      interval: 5s
      timeout: 3s
      retries: 5
      # Failures in this window don't count, which covers first-run initdb on a fresh volume
      start_period: 30s

  redis:
    image: redis:7
    healthcheck:
      # redis-cli ping exits non-zero unless the server answers
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

  api:
    build: .
    environment:
      DATABASE_URL: postgres://app:example-dev-password@postgres:5432/app_dev
      REDIS_URL: redis://redis:6379
    depends_on:
      # Long form: wait for a passing healthcheck, not just container start.
      # This applies at startup only; the api still needs connection retries.
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

# Check it: docker compose up -d && docker compose ps  (STATUS shows starting/healthy/unhealthy)

A Compose file where the api waits for Postgres and Redis to pass their pg_isready and redis-cli ping healthchecks, using condition: service_healthy and a start_period that covers first-run initialisation.

Volumes for state and code: named volumes, bind mounts and resets

Two kinds of storage cover most of a dev stack, and they serve opposite purposes. A named volume holds database state. Docker manages it, and it survives docker compose down, so you keep your data between sessions. A bind mount maps your source tree into the container, so a file watcher inside the container (nodemon, uvicorn --reload, and similar) sees edits immediately without a rebuild.

Mount type Typical use Survives down Removed by down -v
Named volume Database data directory Yes Yes
Bind mount Application source code Yes (it's your host files) No
Anonymous volume node_modules, virtualenv Yes, reused on recreate Yes

Treat docker compose down -v as a deliberate reset. It wipes all state, so it should never be part of a routine restart.

Pitfalls

  • Shadowed dependencies. Bind-mounting ./ over /app hides the node_modules or .venv that the image installed. To fix this, mount an anonymous or named volume at that exact path. Anonymous volumes carry over when a container is recreated, so after you change dependencies, run up --renew-anon-volumes to get a fresh one.
  • File ownership. On Linux, a container running as root writes root-owned files into your checkout. Run the service with your host UID/GID instead.
  • Slow mounts on macOS and Windows. File I/O crosses a VM boundary there. On Windows, keep the code inside the WSL 2 filesystem. Where your Compose version supports watch, syncing files into the container is an alternative to bind mounts.
  • Stale schemas. Init scripts in images like the official Postgres image run only when the data directory is empty. A persisted volume will therefore silently skip new seed or schema files. Document a team reset procedure and point people to it.
# compose.yaml (dev stack)
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpassword   # placeholder, local only
    volumes:
      # Named volume: data survives `docker compose down`
      - pgdata:/var/lib/postgresql/data
      # Init scripts run ONLY when the data dir is empty.
      # After adding a file here, existing volumes will skip it.
      - ./db/init:/docker-entrypoint-initdb.d:ro

  web:
    build: .                           # Dockerfile runs npm ci into /app/node_modules
    command: npm run dev               # e.g. nodemon watching /app
    # Write files into the checkout as the host user, not root.
    # Set LOCAL_UID/LOCAL_GID in .env (e.g. from `id -u` / `id -g`).
    user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
    volumes:
      # Bind mount: host edits are visible immediately for live reload
      - ./:/app
      # Anonymous volume at the exact path, so the bind mount
      # does not hide the node_modules installed in the image
      - /app/node_modules
    depends_on:
      - db

volumes:
  pgdata:

# Team reset procedure (document it in the README):
#   docker compose down -v            # deliberately deletes pgdata and anon volumes
#   docker compose up -d --build      # init scripts run against an empty data dir
#
# After changing dependencies, keep the DB but replace node_modules:
#   docker compose up -d --build --renew-anon-volumes web

A Compose file that keeps Postgres data in a named volume, bind-mounts source for live reload, protects node_modules with an anonymous volume, runs as the host UID/GID, and records the deliberate reset commands for stale schemas.

Configuration, secrets and dev-only overrides

Compose reads two kinds of variables, and confusing them causes most configuration bugs. The .env file in the project directory is used for interpolation: it fills ${VAR} references inside compose.yaml itself, such as image tags or published ports. It does not automatically reach your containers. To pass values into a container, use the environment key for a few explicit settings, or env_file to load a whole file. When both set the same variable, environment wins. That makes environment the right place for values that must stay fixed, such as NODE_ENV.

Keep real credentials out of the repository

Add .env to .gitignore and commit a .env.example that lists every required variable, with safe placeholder values and a one-line comment explaining each one. New engineers copy it and fill in their own values. The example file also serves as the only reliable list of what the stack expects. For required variables, use the ${VAR:?message} interpolation form so Compose fails at startup with a clear error instead of starting a service with an empty password.

Separate dev-only concerns

When you run Compose without -f, it automatically merges compose.override.yaml on top of compose.yaml. Put debugger ports, source bind mounts and tooling containers there. CI should pass -f compose.yaml explicitly so none of these settings leak into test runs. For optional extras, such as a profiling sidecar, use additional files added with extra -f flags.

Layering makes the effective configuration hard to see. Run docker compose config to print the merged, interpolated result. Avoid pasting its output into tickets or logs, because it contains your resolved secrets.

# compose.yaml (base file, committed, used by CI)
services:
  api:
    image: myorg/api:${API_TAG:-latest}   # .env interpolation; does not reach the container
    env_file: ./api.env                     # loads many vars into the container
    environment:
      NODE_ENV: production                  # overrides the same key from env_file
      DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env}
    depends_on:
      - db
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env}

# compose.override.yaml (dev only; merged automatically when no -f is given)
# services:
#   api:
#     environment:
#       NODE_ENV: development
#     ports:
#       - "9229:9229"          # Node debugger
#     volumes:
#       - ./src:/app/src       # live-reload bind mount

# .env.example (committed; copy to .env, which is gitignored)
# API_TAG=latest              # image tag to run locally
# DB_PASSWORD=change-me       # local Postgres password

# Usage:
#   docker compose up                       # base + override (dev)
#   docker compose -f compose.yaml up       # base only (CI)
#   docker compose config                   # inspect merged result (prints secrets)

A base compose.yaml combining .env interpolation, env_file and environment with required-variable checks, plus a dev-only override file, a committed .env.example and the commands to run or inspect each layer.

Implementing the daily workflow: lifecycle commands, migrations and seeds

Start the day with docker compose up --build. The --build flag rebuilds images whose build context changed, so a new dependency in a lockfile does not leave you running a stale image. For a noisy stack, follow one service with docker compose logs -f api rather than reading interleaved output from every container.

For one-off tasks, choose between exec and run --rm. docker compose exec api sh enters the container that is already running, with its live state and open ports. docker compose run --rm api <command> starts a fresh container from the service definition, runs the command, and deletes the container afterwards. Use run when the task should not interfere with the running process. By default, run also starts the services it depends on.

To end the day, docker compose stop halts containers and keeps them for a fast restart. docker compose down removes containers and networks. Named volumes survive down unless you add -v.

Migrations and seeds as a service

Define a migrate service that applies the schema and seed data, then exits. Make api depend on it with condition: service_completed_successfully. With this setup, api never starts against an old schema, and a failed migration stops startup with a visible error. Because the service runs on every up, migrations and seeds must be idempotent.

Aspect Dedicated one-off service Manual run --rm
When it runs Every up Only when someone remembers
Ordering guarantee Enforced by Compose None
Startup cost Added on every start Paid only when needed
Best for Shared team stacks Rare or destructive tasks such as resets

When to avoid Compose and what the alternatives are

Compose is good at starting a stack on one machine. It does not reproduce production. Containers share a single bridge network on one host, so service discovery, TLS termination, load balancing, and network policy all behave differently from your real environment. A Postgres container is not the managed database you run in production: failover, connection limits, IAM-based auth and maintenance events are all missing. Compose also has no rolling updates, pod rescheduling or autoscaling. Treat "works in Compose" as a check that the services fit together, not as a deployment test.

Resource limits become a problem as the stack grows. Every service uses memory and CPU on the developer's laptop, and a dozen databases, brokers and search nodes can exhaust it. Profiles help by letting each developer start only the services they need, and stubs can stand in for heavy dependencies. Both trade away fidelity: anything you don't run is something you don't test.

Approach Strength Cost
Native on host Fastest feedback, simplest debugging Version drift between machines, setup steps that only live in people's heads
Local Kubernetes Closer to production orchestration semantics Heavier, slower loops, more tooling to learn
Shared remote dev environment Real managed services, no laptop limits Network dependency, contention between developers, ongoing hosting spend
Mocked dependencies Cheap, deterministic, works offline Mocks drift from real behavior, so integration bugs slip through

Most teams combine these. They use Compose for the services they change every day and mocks or a remote environment for everything else.

Impact on development cost and engineering time

An explicit Compose file saves time by turning tribal knowledge into something that runs. Onboarding shrinks from working through a wiki page to cloning the repository and running one command, because service versions, ports and seed steps are executed rather than remembered. Environment-specific bugs become rarer when everyone runs the same pinned image tags instead of whatever database version a package manager installed months ago. Resets get cheap: removing a named volume and re-seeding takes minutes, so nobody nurses a corrupted local database for a week. Healthcheck-gated startup also removes the "run it again, it works the second time" class of race, which costs debugging hours and rarely gets filed as a bug.

The costs are ongoing. Healthchecks and override files are code. They break when a service changes its port or startup behavior, and someone has to own them. Pinned tags protect reproducibility, but upgrades must then be scheduled deliberately or the local stack drifts from production. Laptop memory and CPU cap how many services you can run before the stack slows everyone down.

Illustrative estimate (assumed numbers, not a benchmark)

Formula: annual hours saved = (new hires × setup hours saved per hire) + (engineers × troubleshooting hours saved per month × 12) − maintenance hours per year

Assumed inputs: 6 new hires per year, each saving 6 setup hours; 10 engineers, each saving 1 hour per month; 40 hours of maintenance per year.

(6 × 6) + (10 × 1 × 12) − 40 = 36 + 120 − 40 = 116 hours per year

If your own numbers come out near zero or negative, the stack is probably too large. Move rarely changed services to mocks or a shared environment.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to docker compose local development, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.