The docker-compose.yml File

A docker-compose.yml file is the recipe Docker Compose reads to create a multi-container application. Instead of typing long docker run commands for each container, you describe services, networks, volumes, ports, environment variables, and build settings in one YAML file. This matters because real applications usually need more than one process: a web app, a database, a cache, a worker, and shared configuration that should be repeatable on every machine.

Overview: How The File Works

Compose is a thin orchestration layer on top of the same Docker Engine you already use with docker run, docker build, and docker volume. The modern command is docker compose, with a space, because Compose V2 is a Docker CLI plugin. The old standalone docker-compose command still exists in some environments, but new lessons and new projects should use docker compose.

When you run docker compose up, the CLI reads docker-compose.yml from the current directory by default. It validates the YAML, resolves variables such as ${POSTGRES_PASSWORD}, creates a project name, builds any services with a build: section, pulls any missing images named by image:, creates networks and volumes, and then creates containers for each service. Each service container is still a normal Docker container: it has an image, a thin writable layer, environment variables, mounts, network attachments, and one main process.

The important mental model is that Compose does not create a special kind of container. It creates ordinary Docker objects with consistent names and labels so they can be managed together. A service named web may become a container like myproject-web-1. A named volume such as db-data becomes a Docker-managed volume, and a default network lets services resolve each other by service name. This is why an app container can connect to postgres:5432 without knowing the database container’s IP address.

A Compose file is declarative. You describe the desired local application, then Compose compares that description with existing containers. If the config changes, Compose recreates only what it needs to recreate. If an image is unchanged and the service definition is unchanged, the existing container can often be reused. This makes the file both documentation and automation: it records how the development or small deployment stack is supposed to run.

Syntax

The file is YAML. Indentation matters, lists start with -, and strings containing punctuation are often clearer when quoted. A practical top-level structure looks like this:

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: "changeme"
volumes:
  db-data:
networks:
  app-net:
Key Purpose
services Defines the containers Compose should run. Each service becomes one or more containers.
image Uses an existing image, usually from Docker Hub or another registry. Pin specific tags instead of using latest.
build Builds an image from a local Dockerfile before starting the service.
ports Publishes container ports to the host, similar to docker run -p.
environment Sets environment variables inside the container. Do not put real production secrets in source control.
env_file Loads environment variables from a file, commonly .env for local development.
volumes Mounts named volumes or host paths into containers.
depends_on Controls startup order. By itself, it does not prove the dependency is ready to accept traffic.
networks Attaches services to specific Docker networks. Most small apps can use the default network.
restart Configures restart behavior, such as unless-stopped, for containers managed by the Docker daemon.

The command shape is usually simple:

docker compose up -d
docker compose ps
docker compose logs web
docker compose down

up creates and starts the application. -d runs it in the background. ps lists containers in the Compose project. logs web shows logs for one service. down stops and removes the containers and default network; it does not remove named volumes unless you add --volumes.

Examples

Example 1: One Nginx Service

This smallest useful Compose file starts a web server and publishes it on host port 8080.

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"

Run it with:

docker compose up -d
docker compose ps

Output:

NAME                IMAGE               COMMAND                  SERVICE   STATUS          PORTS
site-web-1          nginx:1.27-alpine   "/docker-entrypoint.…"   web       Up 3 seconds    0.0.0.0:8080->80/tcp

Compose creates one service container from the pinned nginx:1.27-alpine image. The ports entry maps host port 8080 to container port 80. This is the Compose version of docker run -p 8080:80 nginx:1.27-alpine.

Example 2: Web App Plus Database

A more realistic file has an application service and a PostgreSQL database. The database stores its data in a named volume so deleting the container does not delete the database files.

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: "postgres://app:changeme@db:5432/appdb"
    depends_on:
      - db
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: "app"
      POSTGRES_PASSWORD: "changeme"
      POSTGRES_DB: "appdb"
    volumes:
      - db-data:/var/lib/postgresql/data
volumes:
  db-data:
docker compose up --build -d
docker compose logs api

Output:

api-1  | Server listening on port 3000
api-1  | Connected to database host db

The api service is built locally, while db is pulled from the registry if needed. Both services join the same default network, so the hostname db resolves to the database container. depends_on starts db before api, but your application should still retry database connections because PostgreSQL may need a few seconds before it is ready.

Example 3: A Complete Development File

For local development, bind mount the source code into the container and use a named volume for dependencies that should stay inside Docker.

services:
  web:
    build: .
    command: npm run dev -- --host 0.0.0.0
    ports:
      - "5173:5173"
    environment:
      NODE_ENV: "development"
    volumes:
      - .:/app
      - node-modules:/app/node_modules
volumes:
  node-modules:
docker compose up --build

Output:

web-1  | VITE v5.4.0 ready in 420 ms
web-1  | Local:   http://localhost:5173/
web-1  | Network: http://172.19.0.2:5173/

The bind mount .:/app makes source changes visible inside the container immediately. The named volume at /app/node_modules prevents the host bind mount from hiding container-installed dependencies. This pattern is common for Node.js development, but production Compose files usually avoid bind mounting application source.

How It Works Step By Step

  1. Compose chooses a project name, usually from the directory name unless you set one with --project-name or COMPOSE_PROJECT_NAME.
  2. It parses docker-compose.yml and any additional files passed with -f. Later files can override earlier ones.
  3. It substitutes variables from the shell environment and the local .env file. The .env file is for Compose interpolation; env_file is for variables passed into containers.
  4. It creates networks and named volumes declared at the top level. Unnamed defaults are created when needed.
  5. It builds services with build:. Docker still uses normal image layers and the build cache; changing a Dockerfile line invalidates that layer and later layers.
  6. It pulls missing images for services with image:.
  7. It creates containers with the configured command, environment, mounts, ports, labels, and network aliases.
  8. It streams logs in the foreground, or returns control to your terminal when -d is used.

Stopping the stack with docker compose down removes the service containers and the project network. Named volumes remain because Docker treats them as persistent data. To remove them, use docker compose down --volumes only when you truly want to delete that data.

Common Mistakes

Using latest in a shared file

services:
  db:
    image: postgres:latest

latest is a moving tag. A teammate or CI runner may pull a different PostgreSQL version tomorrow. Pin a real version instead:

services:
  db:
    image: postgres:16-alpine

Expecting depends_on to wait for readiness

services:
  api:
    image: mycompany/api:1.4.2
    depends_on:
      - db
  db:
    image: postgres:16-alpine

This starts db before api, but it does not guarantee PostgreSQL is accepting connections. Fix this in the application with connection retries, or add a suitable healthcheck and readiness-aware startup pattern when the stack needs it.

Confusing EXPOSE with published ports

A Dockerfile line such as EXPOSE 3000 is metadata and documentation. It does not publish a port to the host. In Compose, use ports: to publish a port:

services:
  api:
    image: mycompany/api:1.4.2
    ports:
      - "3000:3000"

Deleting data by removing volumes

docker compose down --volumes

That command removes named volumes declared by the Compose project. It is useful for resetting development data, but dangerous if you expected a database volume to survive.

Best Practices

  • Use docker compose, not the legacy docker-compose, for modern projects.
  • Pin image tags such as postgres:16-alpine and nginx:1.27-alpine instead of using latest.
  • Commit docker-compose.yml when it describes the shared development stack.
  • Keep real secrets out of the Compose file. Use obvious local placeholders, ignored env files, Docker secrets, or your deployment platform’s secret store.
  • Use named volumes for persistent service data, especially databases.
  • Use bind mounts for local source-code editing, not for production application code.
  • Prefer service names for internal communication, such as http://api:3000 or db:5432.
  • Keep Compose files small enough to understand. Split development overrides into a second file when needed.
  • Use docker compose config to view the fully resolved configuration before debugging a confusing stack.

Practice Exercises

  1. Create a Compose file with two services: web using nginx:1.27-alpine and redis using redis:7-alpine. Publish only the web service to the host.
  2. Modify a database service so PostgreSQL data survives docker compose down. Hint: use a top-level named volume and mount it at PostgreSQL’s data directory.
  3. Run docker compose config on a file that uses ${APP_PORT}. Try setting APP_PORT in your shell and then in a .env file to see how interpolation changes the resolved config.

Summary

  • docker-compose.yml describes a multi-container Docker application in YAML.
  • Each Compose service becomes ordinary Docker containers, images, networks, and volumes managed as one project.
  • ports: publishes ports; Dockerfile EXPOSE alone does not.
  • Service names work as DNS names on the Compose network.
  • Named volumes preserve data until you explicitly remove them.
  • Pin image versions, avoid real secrets in source control, and use docker compose config when debugging.