Module P-4·35 min read

Compose v2 workflows, profiles, overrides, and managing boot order with depends_on and health checks.

Introduction

Running docker run manually with ten different flags (-p, -v, -e, --network) works for a single container. But a real application rarely consists of a single container.

A modern SaaS application might require:

  1. A Next.js frontend
  2. A Node.js API
  3. A PostgreSQL database
  4. A Redis cache

Typing out four complex docker run commands in the exact right order every time you want to develop locally is unsustainable.

Docker Compose is an orchestration tool that allows you to define your entire multi-container environment in a single declarative YAML file. In this module, we will explore Compose v2, managing boot order, and utilizing overrides for different environments.


The Reality of Compose V2

If you learned Docker years ago, you probably used docker-compose (with a hyphen). This was Compose V1, written in Python.

Docker has since completely rewritten Compose in Go and integrated it directly into the Docker CLI as a plugin.

  • Old Way: docker-compose up
  • New Way: docker compose up

Similarly, the configuration file is now officially named compose.yaml (though docker-compose.yml is still supported for backwards compatibility). Always prefer Compose V2 and compose.yaml for new projects.


Anatomy of a compose.yaml

A compose.yaml file defines services, networks, and volumes.

Here is a complete local development environment for a full-stack application:

yaml
# compose.yaml name: my-saas-app services: # 1. The PostgreSQL Database db: image: postgres:15-alpine environment: POSTGRES_USER: admin POSTGRES_PASSWORD: secretpassword POSTGRES_DB: saas_db ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U admin -d saas_db"] interval: 5s timeout: 5s retries: 5 # 2. The Redis Cache cache: image: redis:7-alpine ports: - "6379:6379" # 3. The Node.js API api: build: context: ./api target: dev # Target a specific stage in a multi-stage Dockerfile environment: DATABASE_URL: postgres://admin:secretpassword@db:5432/saas_db REDIS_URL: redis://cache:6379 ports: - "8080:8080" volumes: - ./api:/app # Bind mount for hot-reloading - /app/node_modules # Anonymous volume to protect container's node_modules depends_on: db: condition: service_healthy cache: condition: service_started volumes: pgdata: # Declare the named volume used by the db service

What Compose does for you:

When you run docker compose up, Compose automatically:

  1. Creates a custom bridge network named my-saas-app_default.
  2. Creates the pgdata volume if it doesn't exist.
  3. Builds the api image from the ./api directory.
  4. Starts all containers and attaches them to the custom network, enabling internal DNS (the api can connect to db:5432).
  5. Streams the interleaved logs from all three containers to your terminal.

Managing Boot Order (depends_on)

In the example above, the api container will crash immediately if the db container isn't ready to accept connections.

A common mistake is using a simple depends_on:

yaml
depends_on: - db

This only tells Compose to start the db container before the api container. But PostgreSQL takes several seconds to initialize its internal data directory before it can accept TCP connections on port 5432. The api container will still crash!

The robust solution: Use Healthchecks.

  1. Define a healthcheck on the database service (using pg_isready).
  2. Tell the API to wait until that healthcheck passes:
yaml
depends_on: db: condition: service_healthy

Now, Compose will start the database, continuously poll pg_isready, and only launch the API after Postgres confirms it is fully booted.


The Anonymous Volume Trick (node_modules)

Look closely at the api volumes:

yaml
volumes: - ./api:/app - /app/node_modules

If you bind-mount your local ./api directory into /app to enable hot-reloading, you will accidentally overwrite the container's Linux-compiled node_modules with your host machine's macOS/Windows node_modules. This breaks native C++ bindings (like bcrypt or sharp).

The second volume (- /app/node_modules) is an Anonymous Volume. It tells Docker: "Bind mount the whole /app folder, but except the node_modules directory. Leave the container's version of that folder intact."

This is a critical trick for local Node.js development in Compose.


Overrides for Different Environments

You should never use bind-mounts or target dev build stages in production.

Compose allows you to define a base compose.yaml with your core services, and then layer override files on top of it.

By default, running docker compose up will read compose.yaml and then automatically merge compose.override.yaml on top of it.

You can define production settings in a separate file (e.g., compose.prod.yaml) and apply them manually:

bash
docker compose -f compose.yaml -f compose.prod.yaml up -d

This allows you to maintain a single source of truth for your architecture while hot-swapping development and production configurations.


Key Takeaways

  1. Compose V2: Use docker compose (no hyphen) and name your file compose.yaml.
  2. Implicit Networking: Compose automatically creates a user-defined bridge network, enabling easy DNS resolution between services.
  3. Boot Order: Use depends_on: condition: service_healthy coupled with a database healthcheck to prevent your Node.js app from crashing on startup.
  4. Anonymous Volumes: Use them to protect the container's node_modules from being overwritten by a host bind-mount during local development.

Knowledge Check

Question 1: You have defined depends_on: [ "db" ] for your Node.js API service in compose.yaml. However, the Node.js API still crashes on startup, complaining that the database connection failed. Why?

  • A) depends_on does not attach the services to the same network.
  • B) depends_on only ensures the database container starts first; it does not wait for the database process inside the container to be ready to accept connections.
  • C) You need to publish the database port to the host machine for the API to reach it.
  • D) Compose v2 no longer supports depends_on.
Reveal Answer

Correct Answer: B

Starting a container is nearly instantaneous, but the application inside it (like PostgreSQL) might take several seconds to boot. To guarantee boot order, you must combine depends_on with a condition: service_healthy check.


Question 2: When configuring volumes for a Node.js API in compose.yaml for local development, you include two volume definitions: - ./api:/app and - /app/node_modules. What is the specific architectural purpose of the second "Anonymous Volume" definition?

  • A) It ensures the node_modules directory is automatically synced back to the host machine for local code completion in the IDE.
  • B) It prevents the host machine's potentially incompatible (e.g., macOS/Windows) node_modules directory from overwriting the Linux-native node_modules compiled inside the container.
  • C) It instructs Docker to delete the node_modules directory every time the container stops to save disk space.
  • D) It tells Docker to prioritize loading dependencies from the global npm cache before using the local project cache.
Reveal Answer

Correct Answer: B

When you bind mount your entire local source folder (./api:/app), you overwrite the container's /app folder. If you ran npm install on your Mac, those Mac-specific binaries (like bcrypt or sharp) will break the Linux container. The anonymous volume trick (/app/node_modules) creates an exception to the bind mount, telling Docker to preserve the container's internally built node_modules folder, shielding it from the host's files.


Question 3: Your team uses a compose.yaml file that includes bind mounts and targeting a dev build stage for local development. However, these settings should never be used in production. What is the Docker Compose best practice for managing these environment-specific differences?

  • A) Write a bash script that uses sed to automatically rewrite the compose.yaml file before deploying to production.
  • B) Maintain a single compose.yaml containing the core architecture, and use a separate compose.prod.yaml override file applied via the -f flag during deployment to strip out dev-specific settings.
  • C) Store environment variables inside a .env file to conditionally skip bind mounts when the variable NODE_ENV=production is detected.
  • D) Copy and paste the entire file into a new docker-compose-production.yml file and maintain them completely separately.
Reveal Answer

Correct Answer: B

Docker Compose natively supports configuration merging. The best practice is to keep the shared, unchangeable topology in compose.yaml (the base file), and use override files (like compose.override.yaml for local dev or compose.prod.yaml for production) to layer environment-specific instructions (like ports, bind mounts, or restart policies) on top.

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.