Most real-world applications aren't single containers. A typical web app needs a web server, a database, maybe a cache layer, and a reverse proxy. Managing these separately with docker run commands gets tedious fast—you'd need to remember port mappings, volume mounts, environment variables, and startup order for each container.
What is Docker Compose?
Docker Compose is a tool for defining and running multi-container applications. You describe your services, networks, and volumes in a docker-compose.yml file, then use simple commands to manage the entire stack.
Why use Compose over individual docker commands?
- Reproducibility: Your entire stack is defined in code, easy to version control and share
- Simplicity:
docker compose upreplaces dozens ofdocker runcommands - Networking: Services automatically discover each other by name
- Dependencies: Define startup order and health checks
- Environment management: Keep development and production configs separate
If you followed Part 1 and installed Docker using the official repository, Docker Compose is already installed as a plugin. Verify with:
docker compose versionCompose File Basics
A Compose file is written in YAML and typically named docker-compose.yml. Here's the basic structure:
services:
service-name:
image: image-name:tag
ports:
- "host:container"
volumes:
- volume-name:/path/in/container
environment:
- VARIABLE=value
volumes:
volume-name:Services
Services are the containers that make up your application. Each service definition specifies which image to use, port mappings, volume mounts, environment variables, resource limits, and dependencies.
services:
web:
image: nginx:1.25
ports:
- "80:80"Volumes
Named volumes persist data beyond the container lifecycle. Define them at the top level and reference them in services:
services:
database:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:Networks
Compose creates a default network automatically. You can also define custom networks for isolation:
services:
web:
networks:
- frontend
api:
networks:
- frontend
- backend
database:
networks:
- backend
networks:
frontend:
backend:In this example, web can talk to api, and api can talk to database, but web cannot directly reach database.
Essential Compose Options
Image vs Build
You can use pre-built images or build from a Dockerfile:
services:
# Using a pre-built image
nginx:
image: nginx:1.25
# Building from a Dockerfile
app:
build:
context: ./app
dockerfile: DockerfilePort Mapping
services:
web:
ports:
- "80:80" # host:container
- "443:443"
- "127.0.0.1:8080:80" # Only accessible from localhostVolume Mounts
services:
web:
volumes:
# Named volume (managed by Docker)
- app-data:/var/www/html
# Bind mount (host directory)
- ./config:/etc/nginx/conf.d:ro
# Anonymous volume (not persisted after removal)
- /var/cache/nginx
volumes:
app-data:The :ro suffix makes the mount read-only inside the container.
Environment Variables
Three ways to set environment variables:
services:
app:
# Method 1: Inline
environment:
- DATABASE_HOST=db
- DATABASE_PORT=5432
# Method 2: Key-value format
environment:
DATABASE_HOST: db
DATABASE_PORT: 5432
# Method 3: From a file
env_file:
- .envImportant: Add .env to your .gitignore to avoid committing secrets.
Restart Policies
services:
web:
restart: unless-stopped # Recommended for productionOptions: no (default), always, on-failure, unless-stopped
Dependencies and Health Checks
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
web:
depends_on:
db:
condition: service_healthyThis ensures web waits until db is actually ready, not just started.
Resource Limits
Prevent a single container from consuming all your VPS resources:
services:
app:
deploy:
resources:
limits:
cpus: '0.5' # Half a CPU core
memory: 512M
reservations:
cpus: '0.25'
memory: 256MCompose Commands
Starting and Stopping
# Start all services (detached)
docker compose up -d
# Start specific services
docker compose up -d web db
# Stop all services
docker compose down
# Stop and remove volumes (destroys data!)
docker compose down -vViewing Status and Logs
# List running services
docker compose ps
# View logs
docker compose logs
# Follow logs for specific service
docker compose logs -f web
# Last 100 lines
docker compose logs --tail 100 webManaging Services
# Restart a service
docker compose restart web
# Stop a service without removing
docker compose stop web
# Start a stopped service
docker compose start web
# Rebuild and restart (after code changes)
docker compose up -d --build
# Pull latest images
docker compose pullExecuting Commands
# Run a command in a running container
docker compose exec db psql -U postgres
# Run a one-off command in a new container
docker compose run --rm web npm installPractical Example: WordPress with MariaDB
Let's deploy a complete WordPress site with a database. This demonstrates multi-container orchestration in a real-world scenario.
Create a project directory:
mkdir ~/wordpress && cd ~/wordpressCreate docker-compose.yml:
services:
wordpress:
image: wordpress:6.4-php8.2-apache
restart: unless-stopped
ports:
- "80:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
WORDPRESS_DB_NAME: wordpress
volumes:
- wordpress-data:/var/www/html
depends_on:
db:
condition: service_healthy
db:
image: mariadb:11
restart: unless-stopped
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
volumes:
wordpress-data:
db-data:Create .env with your passwords:
DB_PASSWORD=your-secure-password-here
DB_ROOT_PASSWORD=your-root-password-hereStart the stack:
docker compose up -dWhat's Happening Here
- Service discovery: WordPress connects to the database using
dbas the hostname - Health checks: WordPress waits until MariaDB reports healthy before starting
- Persistent storage: Both WordPress files and database data survive restarts
- Environment variables: Passwords are loaded from
.env - Restart policy: Both services restart automatically after crashes or reboots
Managing Your WordPress Stack
# Backup the database
docker compose exec db mariadb-dump -u root -p wordpress > backup.sql
# Update WordPress
docker compose pull
docker compose up -d
# View resource usage
docker statsDevelopment Stack with Hot Reload
Here's a Node.js development environment with MongoDB and Redis:
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules # Exclude node_modules from bind mount
environment:
- NODE_ENV=development
- MONGODB_URI=mongodb://mongo:27017/myapp
- REDIS_URL=redis://redis:6379
depends_on:
- mongo
- redis
command: npm run dev
mongo:
image: mongo:7
volumes:
- mongo-data:/data/db
ports:
- "127.0.0.1:27017:27017" # Only accessible locally
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
mongo-data:
redis-data:The bind mount (- .:/app) syncs your local code into the container, enabling hot reload during development.
Reverse Proxy with Multiple Apps
Running multiple web applications on one VPS? Use Nginx as a reverse proxy:
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- app1
- app2
restart: unless-stopped
app1:
image: your-app-1:latest
expose:
- "3000"
restart: unless-stopped
app2:
image: your-app-2:latest
expose:
- "3000"
restart: unless-stoppedNote: expose makes the port available to other containers but doesn't publish it to the host. Only Nginx is accessible from outside.
A basic nginx.conf for this setup:
events {
worker_connections 1024;
}
http {
upstream app1 {
server app1:3000;
}
upstream app2 {
server app2:3000;
}
server {
listen 80;
server_name app1.yourdomain.com;
location / {
proxy_pass http://app1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
server {
listen 80;
server_name app2.yourdomain.com;
location / {
proxy_pass http://app2;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}Managing Secrets Securely
For production, avoid putting passwords directly in .env files that might accidentally get committed.
Docker secrets (Swarm mode):
services:
db:
image: postgres:16
secrets:
- db_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txtEnvironment variables from the host:
services:
app:
environment:
- API_KEY # Reads from host's $API_KEYSet the variable before running:
export API_KEY=your-key
docker compose up -dCompose File Organization
As your stack grows, keep things maintainable:
Use multiple Compose files for environments:
# Base configuration
docker-compose.yml
# Development overrides
docker-compose.override.yml # Automatically loaded
# Production overrides
docker-compose.prod.yml
# Start with production config
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dExample override for development:
services:
app:
build: .
volumes:
- .:/app
environment:
- DEBUG=trueExample production overrides:
services:
app:
image: your-registry/app:latest
deploy:
resources:
limits:
memory: 1G
environment:
- DEBUG=falseResource Considerations
Running multiple containers on a VPS requires planning:
| Stack | Minimum RAM | Recommended |
|---|---|---|
| WordPress + MariaDB | 1GB | 2GB |
| Node app + MongoDB + Redis | 2GB | 4GB |
| 3-4 services + reverse proxy | 2GB | 4GB |
| Full monitoring stack | 4GB | 8GB |
Tips for smaller VPS instances:
- Use Alpine-based images when available (e.g.,
nginx:alpine,redis:alpine) - Set memory limits to prevent runaway containers
- Disable development features in production (debug modes, verbose logging)
- Consider using SQLite instead of a full database for simple apps
Troubleshooting
Container keeps restarting:
docker compose logs service-nameCheck for configuration errors, missing environment variables, or dependency issues.
Service can't connect to database:
- Verify both services are on the same network
- Check the hostname matches the service name
- Ensure the database is healthy before the app starts (use
depends_onwith health checks)
Changes not taking effect:
docker compose up -d --force-recreatePort conflict:
# Find what's using the port
sudo lsof -i :80Volume permissions:
If a container can't write to a volume, check if the container runs as a non-root user:
docker compose exec service-name chown -R user:group /pathWhat's Next
You now have the tools to deploy complex multi-container applications with Docker Compose. In Part 3, we'll cover production hardening: resource monitoring, logging strategies, automated updates, backups, and security best practices to run Docker reliably on your VPS.
