Tutorial

Docker Compose Tutorial: Multi-Container Apps from Dev to Production

April 29, 2026

Back to Blog

What Is Docker Compose and Why Should You Use It?

Running a single Docker container is straightforward. But real-world applications rarely consist of just one container. A typical web application might need a web server, an application runtime, a database, a cache layer, and perhaps a background worker. Managing these containers individually with long docker run commands — remembering ports, volumes, networks, environment variables, and startup order — quickly becomes unmanageable.

Docker Compose solves this by letting you define your entire multi-container application in a single YAML file. Instead of running five separate docker run commands, you write one docker-compose.yml and bring everything up with a single docker compose up. It handles networking between containers, manages volumes, respects dependencies, and makes your deployment reproducible across development, staging, and production environments.

Compose V2 Note: Docker Compose V2 is now built into the Docker CLI as docker compose (with a space). The older standalone docker-compose (with a hyphen) is deprecated. This tutorial uses the modern V2 syntax throughout.

Understanding the docker-compose.yml Structure

A Compose file is built around three primary concepts: services, networks, and volumes. Let us start with a minimal example and build from there.

A Minimal Compose File

# docker-compose.yml services: web: image: nginx:alpine ports: - "8080:80" volumes: - ./html:/usr/share/nginx/html:ro

This file defines one service called web that runs the official nginx image, maps port 8080 on your host to port 80 in the container, and mounts a local ./html directory as read-only content.

Key Directives Explained

DirectivePurposeExample
imageDocker image to usemysql:8.0
buildBuild from Dockerfilebuild: ./app
portsHost:container port mapping"3306:3306"
volumesPersistent storagedb_data:/var/lib/mysql
environmentEnvironment variablesMYSQL_ROOT_PASSWORD: secret
env_fileLoad vars from fileenv_file: .env
depends_onStartup orderingdepends_on: [db, cache]
restartRestart policyrestart: unless-stopped
networksNetwork attachmentnetworks: [frontend, backend]
deployResource limits (Swarm/Compose)deploy: resources: limits:
healthcheckContainer health monitoringhealthcheck: test: ["CMD", "curl", "-f", "http://localhost"]
commandOverride default commandcommand: ["--max-connections=200"]

Building a Real-World Stack: WordPress + MySQL + Cache

Let us build a complete, production-ready WordPress stack. This example demonstrates almost every Compose feature you will need in practice.

# docker-compose.yml services: wordpress: image: wordpress:6.4-php8.2-fpm-alpine restart: unless-stopped depends_on: db: condition: service_healthy environment: WORDPRESS_DB_HOST: db:3306 WORDPRESS_DB_USER: ${DB_USER:-wordpress} WORDPRESS_DB_PASSWORD: ${DB_PASSWORD} WORDPRESS_DB_NAME: ${DB_NAME:-wordpress} volumes: - wp_data:/var/www/html - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro networks: - frontend - backend nginx: image: nginx:alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - wp_data:/var/www/html:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./certs:/etc/nginx/certs:ro depends_on: - wordpress networks: - frontend db: image: mysql:8.0 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MYSQL_DATABASE: ${DB_NAME:-wordpress} MYSQL_USER: ${DB_USER:-wordpress} MYSQL_PASSWORD: ${DB_PASSWORD} volumes: - db_data:/var/lib/mysql - ./my.cnf:/etc/mysql/conf.d/custom.cnf:ro healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 networks: - backend volumes: wp_data: db_data: networks: frontend: backend: internal: true

What Makes This Production-Ready

1
Health-Based Dependencies: WordPress waits for the db service to be healthy (not just started). The MySQL healthcheck runs mysqladmin ping every 10 seconds, and WordPress only starts once MySQL passes 5 consecutive checks.
2
Environment Variables with Defaults: Using ${DB_USER:-wordpress} syntax means the variable falls back to "wordpress" if not set in the .env file. Sensitive values like passwords come from the .env file, never hardcoded.
3
Network Isolation: The backend network is marked internal: true, meaning containers on it cannot reach the internet. MySQL is only on the backend network — it cannot be accessed from outside. WordPress sits on both networks: frontend for nginx communication, backend for database access.
4
Named Volumes: wp_data and db_data are named volumes managed by Docker. They persist across container restarts and docker compose down (unless you pass -v).
5
Read-Only Mounts: Configuration files are mounted with :ro (read-only), preventing containers from accidentally modifying host files.

The .env File

# .env DB_ROOT_PASSWORD=super_secure_root_pass_2026 DB_USER=wordpress DB_PASSWORD=another_secure_password DB_NAME=wordpress_prod
Security Warning: Never commit the .env file to version control. Add it to your .gitignore immediately. Instead, provide a .env.example file with placeholder values that developers can copy and fill in.

Named Volumes vs Bind Mounts

Docker offers two primary ways to persist data: named volumes and bind mounts. Understanding the difference is crucial for production deployments.

Named Volumes

volumes: - db_data:/var/lib/mysql
  • Managed by Docker engine
  • Stored in /var/lib/docker/volumes/
  • Portable across hosts
  • Better performance on macOS/Windows
  • Best for: databases, application data

Bind Mounts

volumes: - ./config:/etc/app/config:ro
  • Maps a host directory directly
  • Changes visible immediately on both sides
  • You control the exact location
  • Best for: config files, source code (dev)
Production Rule: Use named volumes for any data that needs to survive container recreation (database files, uploaded media, etc.). Use bind mounts only for configuration files and development source code.

Essential Docker Compose Commands

CommandDescription
docker compose up -dStart all services in detached (background) mode
docker compose downStop and remove containers, networks (preserves volumes)
docker compose down -vStop, remove, and delete volumes (data loss!)
docker compose psList running containers and their status
docker compose logs -fFollow live logs from all services
docker compose logs -f dbFollow logs from a specific service
docker compose exec web shOpen a shell inside a running container
docker compose buildBuild or rebuild images from Dockerfiles
docker compose pullPull latest images from registry
docker compose restart webRestart a specific service
docker compose stopStop services without removing containers
docker compose configValidate and display the resolved Compose file
docker compose topDisplay running processes inside containers

Common Workflows

# Start the stack $ docker compose up -d [+] Running 4/4 ✔ Network myapp_backend Created ✔ Network myapp_frontend Created ✔ Container myapp-db-1 Healthy ✔ Container myapp-web-1 Started # Check service status $ docker compose ps NAME SERVICE STATUS PORTS myapp-db-1 db running 3306/tcp myapp-web-1 web running 0.0.0.0:80->80/tcp myapp-nginx-1 nginx running 0.0.0.0:443->443/tcp # Run a one-off command inside a service $ docker compose exec db mysql -u root -p wordpress # View resource usage $ docker compose top # Rebuild and restart after code changes $ docker compose up -d --build

Building Custom Images with Compose

Instead of using pre-built images, you can point Compose to a Dockerfile and build your application image automatically.

# docker-compose.yml services: api: build: context: ./backend dockerfile: Dockerfile args: - GO_VERSION=1.22 ports: - "3001:3001" environment: - DATABASE_URL=postgres://user:pass@db:5432/app

Multi-Stage Dockerfile for Production

# backend/Dockerfile # Stage 1: Build FROM golang:1.22-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /server ./cmd/server/ # Stage 2: Runtime (tiny image) FROM alpine:3.19 RUN apk --no-cache add ca-certificates COPY --from=builder /server /usr/local/bin/server USER 1000:1000 EXPOSE 3001 ENTRYPOINT ["server"]

Multi-stage builds produce tiny production images. The build stage contains Go compilers, source code, and dependencies (often 1GB+). The final stage contains only the compiled binary and CA certificates — typically under 20MB.

1.2 GB
Single-stage image (with build tools)
18 MB
Multi-stage image (binary only)

Healthchecks: Beyond depends_on

By default, depends_on only waits for a container to start, not to be ready. A MySQL container might be "started" but still initializing its database for another 30 seconds. Healthchecks solve this.

services: db: image: postgres:16-alpine healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 start_period: 30s api: build: ./backend depends_on: db: condition: service_healthy
Container Starts
start_period
30s grace
Health Checks
Every 10s
5 passes
Healthy
Dependents Start

Production Configuration Tips

Restart Policies

PolicyBehaviorUse Case
noNever restartOne-shot tasks, debugging
alwaysAlways restart, even on clean exitCritical services that must run
unless-stoppedRestart unless manually stoppedBest for production
on-failure:5Restart on failure, max 5 attemptsServices with known crash bugs

Resource Limits

services: api: image: myapp:latest deploy: resources: limits: cpus: '2.0' memory: 512M reservations: cpus: '0.5' memory: 256M
Memory Limits Are Critical: Without memory limits, a container with a memory leak can consume all host RAM and trigger the OOM killer, potentially taking down other containers and the host system. Always set memory limits in production.

Logging Configuration

services: web: image: nginx:alpine logging: driver: "json-file" options: max-size: "10m" max-file: "3"

Without log rotation, container logs can fill your disk. The json-file driver with max-size and max-file keeps logs manageable. Three 10MB files mean at most 30MB of logs per container.

Environment Management

Docker Compose supports several ways to inject environment variables, each appropriate for different scenarios.

Method Comparison

MethodSecurityBest For
Inline in YAMLLow (visible in VCS)Non-sensitive defaults
.env fileMedium (add to .gitignore)Local development
env_file: directiveMediumPer-service env files
Shell environmentHigh (CI/CD injection)Production, CI/CD pipelines
Docker secretsHighest (encrypted)Swarm mode production
# Use different env files for different environments $ docker compose --env-file .env.production up -d # Override with shell variables $ DB_PASSWORD=supersecret docker compose up -d # Validate variable substitution $ docker compose config

Compose Profiles: Optional Services

Not every service needs to run all the time. Profiles let you define optional services that only start when explicitly requested.

services: web: image: myapp:latest # Always runs (no profile) db: image: postgres:16 # Always runs (no profile) debug: image: busybox profiles: ["debug"] # Only runs when debug profile is active command: sleep infinity mailhog: image: mailhog/mailhog profiles: ["dev"] # Only runs in development ports: - "8025:8025"
# Normal start (only web + db) $ docker compose up -d # Start with dev tools $ docker compose --profile dev up -d # Start with debug container $ docker compose --profile debug up -d

Scaling Services

Need more workers to handle load? Compose can scale services horizontally:

# Scale the worker service to 4 instances $ docker compose up -d --scale worker=4 [+] Running 5/5 ✔ Container myapp-worker-1 Started ✔ Container myapp-worker-2 Started ✔ Container myapp-worker-3 Started ✔ Container myapp-worker-4 Started ✔ Container myapp-web-1 Running
Port Conflict: When scaling a service, do not map it to a specific host port (e.g., "3000:3000"). Multiple containers cannot bind to the same host port. Either remove the port mapping or use a load balancer (like nginx) in front.

Docker Compose with Panelica

Panelica includes built-in Docker Compose management that brings the power of multi-container orchestration into the panel's graphical interface. You can upload docker-compose.yml files, deploy stacks, and manage multi-container applications through the panel's GUI or API without SSH access. The panel handles container lifecycle, shows logs, monitors resource usage, and manages port assignments — all integrated with Panelica's RBAC system so each user can only see and manage their own containers.

Key Advantage: Panelica's Docker module enforces per-user resource limits through Cgroups v2, meaning containers are automatically bounded by the user's allocated CPU, memory, and I/O limits. No single container (or user) can monopolize server resources.

Troubleshooting Common Issues

ProblemCauseSolution
Container exits immediatelyMain process crashes or exitsdocker compose logs servicename
Port already in useAnother service on the same portss -tlnp | grep :PORT
Volume permission deniedUID mismatch between host and containerMatch UIDs or use user: directive
Cannot connect between servicesWrong hostname or missing networkUse service name as hostname, check networks
Changes not reflectedUsing cached imagedocker compose up -d --build --force-recreate
Disk fullUnrotated logs or dangling imagesdocker system prune -a

Conclusion

Docker Compose transforms multi-container management from a series of fragile scripts into a single, declarative, version-controlled file. We have covered the complete lifecycle: writing Compose files with services, networks, and volumes; using healthchecks for reliable startup ordering; managing environments securely; optimizing images with multi-stage builds; and configuring production essentials like restart policies, resource limits, and log rotation.

The key takeaways: always use named volumes for persistent data, never hardcode secrets, set memory limits on every service, use healthchecks instead of naive depends_on, and keep your images small with multi-stage builds. Start with the WordPress stack example in this tutorial, adapt it to your application, and you will have a reproducible, production-ready deployment in minutes.

Share:
See the Demo