Containers

Docker Compose: Complete Guide to Multi-Container Applications (2026)

The standalone docker-compose binary is dead. If you’re still installing it separately or writing version: '3' at the top of your compose files, you’re working with deprecated tooling. Docker Compose is now a CLI plugin, invoked as docker compose (space, not hyphen), and modern compose files no longer need or want the version: key.

Original content from computingforgeeks.com - post 31204

This guide covers Docker Compose from the ground up using the current plugin-based architecture. It walks through compose file syntax, multi-container deployments, networking, environment management, custom builds, and production patterns. Every example uses the modern format and has been tested on real systems.

Tested April 2026 with Docker Compose v5.1.1 (plugin) on Debian 13, Rocky Linux 10, and Fedora 42

Prerequisites

Docker Engine with the Compose plugin must be installed. The docker-compose-plugin package ships automatically when you install Docker CE from the official repository.

If Docker isn’t set up yet, follow the guide for your distribution:

Confirm both Docker and Compose are working:

docker --version
docker compose version

You should see version output for both:

Docker version 29.4.0, build b4526ef
Docker Compose version v5.1.1

If docker compose version returns “docker: ‘compose’ is not a docker command,” the plugin isn’t installed. Install it explicitly:

sudo apt install docker-compose-plugin    # Debian/Ubuntu
sudo dnf install docker-compose-plugin    # Rocky/Fedora/RHEL

Compose File Basics

A compose file is a YAML file (typically named docker-compose.yml) that defines services, networks, and volumes. The modern format starts directly with services:. The old version: key (version: ‘2’, version: ‘3.8’, etc.) was deprecated in Compose v2 and is now ignored entirely. Remove it if you see it in old files.

Here’s the simplest possible compose file:

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

That defines a single service called web using the nginx:alpine image, mapping port 8080 on the host to port 80 in the container. Save this as docker-compose.yml and start it:

docker compose up -d

The -d flag runs containers in the background. Compose creates a network, pulls the image, and starts the container:

[+] Running 2/2
 ✔ Network compose-test_default  Created                          0.1s
 ✔ Container compose-test-web-1  Started                          0.4s

Check the running services:

docker compose ps

The output confirms the container is up and the port mapping is active:

NAME                   IMAGE          COMMAND                  SERVICE   CREATED         STATUS         PORTS
compose-test-web-1     nginx:alpine   "/docker-entrypoint.…"   web       5 seconds ago   Up 5 seconds   0.0.0.0:8080->80/tcp

To tear everything down:

docker compose down

This stops and removes the containers and the default network. Volumes are preserved unless you pass -v.

Multi-Container Application

Compose shines when you need multiple services that talk to each other. A typical setup includes an application, a database, and a cache. Each runs in its own container, and Compose handles the networking between them automatically.

Create a project directory and the compose file:

mkdir -p ~/myapp && cd ~/myapp
vi docker-compose.yml

Add the following configuration:

services:
  app:
    image: node:22-alpine
    working_dir: /app
    volumes:
      - ./app:/app
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://appuser:secret@db:5432/myapp
      REDIS_URL: redis://cache:6379

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 5

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:

Several things worth noting here. The app service uses depends_on with a health condition, so it won’t start until PostgreSQL passes its readiness check. The database stores data in a named volume (pgdata) that persists across restarts. Services reference each other by name: the app connects to db and cache because Compose creates DNS entries for each service on the shared network.

Bring the stack up:

docker compose up -d

Compose pulls all images, creates the network, starts PostgreSQL first (because of the dependency), waits for the health check to pass, then starts the app and cache services.

Key Compose Directives

Understanding the core directives lets you handle most real-world compose files. Here’s what each one does.

image vs build

Use image to pull a pre-built image from a registry. Use build when you need a custom image from a Dockerfile:

services:
  # Pre-built image
  redis:
    image: redis:7-alpine

  # Custom build
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "8080:8080"

The context sets the build directory. Compose looks for a Dockerfile in that directory by default, but you can specify a different filename with the dockerfile key.

ports

Maps host ports to container ports. The format is HOST:CONTAINER:

ports:
  - "8080:80"      # Host 8080 -> Container 80
  - "443:443"      # Same port on both
  - "127.0.0.1:3306:3306"  # Bind to localhost only

Binding to 127.0.0.1 restricts access to the host machine. Without it, the port is exposed on all interfaces, which matters on servers with public IPs.

volumes

Two types: named volumes for persistent data, and bind mounts for sharing files between host and container.

services:
  db:
    volumes:
      - dbdata:/var/lib/postgresql/data   # Named volume
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql  # Bind mount

volumes:
  dbdata:    # Declare named volumes at the top level

Named volumes survive docker compose down. They’re only removed when you explicitly pass -v. Bind mounts are useful for config files and development code that you want to edit on the host.

environment

Set environment variables inline or load them from a file:

services:
  app:
    environment:
      DB_HOST: db
      DB_PORT: 5432
    env_file:
      - .env

Both methods work. Inline variables are visible in the compose file, which is fine for non-sensitive values. Secrets belong in .env files that stay out of version control.

depends_on with health checks

Plain depends_on only controls startup order, not readiness. The database container might be running but not yet accepting connections. Use the condition field to wait for a healthy state:

services:
  app:
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

The app container won’t start until pg_isready returns successfully. This catches the most common race condition in compose deployments.

networks

Compose creates a default bridge network for every project. All services can reach each other by name on that network. Custom networks give you isolation between service groups:

services:
  frontend:
    networks:
      - frontend-net
  backend:
    networks:
      - frontend-net
      - backend-net
  db:
    networks:
      - backend-net

networks:
  frontend-net:
  backend-net:

The frontend service can reach backend, and backend can reach db, but frontend cannot talk to db directly. This is useful for limiting blast radius in multi-tier applications.

restart

Controls what happens when a container exits:

services:
  web:
    restart: unless-stopped   # Restart always, except when manually stopped
  worker:
    restart: on-failure       # Restart only on non-zero exit code
  cron:
    restart: "no"             # Never restart (default)

For production services, unless-stopped is almost always what you want. It survives host reboots (assuming Docker is enabled at boot) and container crashes, but respects manual docker compose stop commands.

Resource limits

Set CPU and memory constraints to prevent a single container from consuming all host resources:

services:
  app:
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 512M
        reservations:
          cpus: "0.5"
          memory: 256M

Limits are hard caps. Reservations guarantee minimum resources. On a shared host running multiple services, these prevent one misbehaving container from starving the others.

Essential Commands Reference

These are the commands you’ll use daily when managing compose deployments.

CommandDescription
docker compose up -dStart all services in background
docker compose downStop and remove containers
docker compose down -vStop and remove containers + volumes
docker compose psList running services
docker compose logs -fFollow logs from all services
docker compose logs appLogs from a specific service
docker compose exec app shShell into a running container
docker compose buildBuild or rebuild images
docker compose pullPull latest images
docker compose restartRestart all services
docker compose configValidate and view resolved config

A few that deserve extra attention: docker compose config is invaluable for debugging. It resolves all variables, merges overrides, and shows the final computed YAML. If something isn’t working, run it first. And docker compose down -v is destructive, since it deletes named volumes. Use it only when you actually want to wipe persistent data.

Environment Variables and .env Files

Hardcoding credentials in a compose file works for development but is a bad habit to carry into production. Use .env files instead.

Create a .env file in the same directory as your compose file:

vi .env

Add your variables:

POSTGRES_USER=appuser
POSTGRES_PASSWORD=strongpassword123
POSTGRES_DB=myapp
APP_PORT=3000

Reference them in the compose file with ${VARIABLE} syntax:

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
  app:
    ports:
      - "${APP_PORT}:3000"

Verify that variables resolve correctly:

docker compose config

The output shows the fully resolved YAML with actual values substituted. If a variable is missing, Compose warns you here rather than failing silently at runtime.

Keep .env out of version control. Add it to your .gitignore and commit a .env.example with placeholder values so other developers know which variables are required.

Custom Dockerfiles with Compose

When pre-built images don’t fit your needs, use the build directive to build custom images directly from Compose.

Create a project structure:

mkdir -p ~/myproject/api
vi ~/myproject/api/Dockerfile

Write a Dockerfile for the API service:

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]

Reference it in the compose file:

services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      NODE_ENV: production

Build and start:

docker compose up -d --build

The --build flag forces a rebuild even if the image already exists. Without it, Compose reuses cached images, which can cause confusion when you’ve changed the Dockerfile or source code.

Networking in Compose

Every Compose project gets a default bridge network named <project>_default. Services on the same network resolve each other by service name. If your compose file defines a db service, other containers on that network can connect to it using db as the hostname.

For most setups, the default network is sufficient. Custom networks become useful when you want to isolate services from each other, for example separating a frontend tier from a backend tier so the frontend can’t reach the database directly.

A three-tier setup with network isolation:

services:
  frontend:
    image: nginx:alpine
    ports:
      - "80:80"
    networks:
      - frontend-net

  backend:
    build: ./backend
    networks:
      - frontend-net
      - backend-net

  db:
    image: postgres:17-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend-net

networks:
  frontend-net:
  backend-net:

volumes:
  pgdata:

The backend service sits on both networks, acting as a bridge. The frontend can reach backend over frontend-net, and backend can reach db over backend-net. There is no path from frontend to db.

Inspect the networks Compose creates:

docker network ls | grep myproject

You’ll see both custom networks listed along with the default one:

NETWORK ID     NAME                       DRIVER    SCOPE
a1b2c3d4e5f6   myproject_backend-net      bridge    local
f6e5d4c3b2a1   myproject_frontend-net     bridge    local

Production Considerations

Compose works well beyond development if you follow a few practices that prevent the most common production failures.

Pin image versions. Using postgres:latest in production means your database image can change between pulls without warning. A minor version bump might be fine, but a major version upgrade can break schemas and clients. Always pin to a specific version like postgres:17-alpine or redis:7.4-alpine.

Set restart policies. Every production service should have restart: unless-stopped. Without it, containers stay dead after a crash or host reboot. You’ll only discover the problem when users start complaining.

Limit resources. A memory leak in one container shouldn’t take down the entire host. Set deploy.resources.limits.memory on every service. If a container hits the limit, Docker kills it and the restart policy brings it back, which is far better than an OOM killer taking out random processes on the host.

Keep secrets out of compose files. Use .env files for sensitive values and add .env to .gitignore. Commit a .env.example with placeholder values so teammates know which variables to set.

Use named volumes for data. Bind mounts are convenient for development but fragile in production. Named volumes are managed by Docker, survive container recreation, and can be backed up with docker volume inspect to find the mount point. The key thing to remember: docker compose down preserves volumes. Only docker compose down -v removes them.

Update safely. Pull new images and recreate containers without downtime:

docker compose pull
docker compose up -d

Compose only recreates containers whose images have changed. Services with unchanged images keep running. For zero-downtime deploys with health checks and rolling updates, consider Docker Swarm mode or Kubernetes.

Migrating from docker-compose (Hyphenated)

If you’re coming from the standalone docker-compose binary (V1), the migration is straightforward. The command syntax is almost identical, with a space replacing the hyphen:

Old (deprecated)New (current)
docker-compose up -ddocker compose up -d
docker-compose downdocker compose down
docker-compose logsdocker compose logs
docker-compose execdocker compose exec

Remove the version: key from your compose files. It served a purpose in Compose V1 to select the schema version, but V2+ determines the format automatically. Leaving it in produces a warning on every run.

One behavioral difference to watch for: container naming changed. Compose V1 used underscores (project_service_1), while V2 uses hyphens (project-service-1). If you have scripts or monitoring that reference containers by name, update the patterns.

Uninstall the old standalone binary to avoid confusion:

sudo rm /usr/local/bin/docker-compose

The plugin-based docker compose is the only supported path going forward.

Troubleshooting Common Issues

Error: “port is already allocated”

Another process on the host is already using the port you’re trying to map. Find it:

sudo ss -tlnp | grep 8080

Either stop the conflicting service or change the host port in your compose file. The container port (right side of the colon) stays the same.

Error: “network not found”

This happens after renaming the project directory or changing the project name. Compose prefixes network names with the project name, and stale references don’t match. Clean up orphan networks:

docker network prune

Then bring the stack up again. Compose recreates the networks with the correct names.

Container exits immediately after starting

Check the container logs for the reason:

docker compose logs app

Common causes include missing environment variables, wrong image tags, or application errors. If the process exits with code 0, it completed successfully but had nothing to keep running. Web servers, databases, and background workers need a foreground process. A container running a one-shot script exits when the script finishes.

Service can’t connect to another service by name

DNS resolution between services only works within the same Docker network. If you’re using custom networks, verify that both services are on the same network. Run docker compose config and check the networks section for each service. A service on frontend-net cannot resolve the hostname of a service that only exists on backend-net.

Related Articles

Containers VMware Octant – Visualize and Monitor Kubernetes Deployments Openshift How To Open a Shell Prompt on an OpenShift Node Automation Use Dagger with GitHub Actions: CI/CD Pipeline Guide Containers Install Lens Desktop – Kubernetes Cluster Management Tool

Leave a Comment

Press ESC to close