Docker compose containers deployment

Docker Compose on VPS: Manage Multi-Container Apps

June 2026 · 8 min read

What Is Docker Compose and Why Use It on VPS?

When your application becomes more complex — with a web server, database, and cache all running together — executing docker run for each container individually becomes a headache. You must remember long flags, create networks manually, manage startup order — Docker Compose solves all these problems with a single YAML file.

In this guide, we'll deploy a real application comprising three services:

This article is suited for those with basic Docker knowledge and who already have a VPS running Ubuntu 22.04 or Debian 12.

Step 1 — Install Docker and Docker Compose on VPS

SSH into your VPS and run the official Docker installation script:

ssh root@your-vps-ip

# Update package list first
apt update && apt upgrade -y

# Install required dependencies
apt install -y ca-certificates curl gnupg lsb-release

# Add Docker GPG key
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg

# Add Docker repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" \
  | tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Docker Engine + Compose plugin
apt update
apt install -y docker-ce docker-ce-cli containerd.io \
               docker-buildx-plugin docker-compose-plugin

Verify the installation was successful:

docker --version
# Docker version 26.x.x

docker compose version
# Docker Compose version v2.x.x
Docker Compose v2 now comes as a plugin (docker compose) instead of a standalone binary (docker-compose). Use the new format directly.

Step 2 — Create Project Structure

Create the project folder and necessary files:

mkdir -p /opt/myapp/{nginx,app}
cd /opt/myapp

# Directory structure to create:
# /opt/myapp/
# ├── docker-compose.yml
# ├── .env
# ├── nginx/
# │   └── default.conf
# └── app/
#     └── index.html  (example)

Create Nginx Configuration

cat > nginx/default.conf << 'EOF'
server {
    listen 80;
    server_name _;

    location / {
        root /usr/share/nginx/html;
        index index.html;
    }

    location /health {
        return 200 'OK';
        add_header Content-Type text/plain;
    }
}
EOF

Create Environment Variables File

Store secrets in a separate .env file instead of hardcoding them in docker-compose.yml:

cat > .env << 'EOF'
POSTGRES_DB=myapp
POSTGRES_USER=appuser
POSTGRES_PASSWORD=SuperSecret123!
REDIS_PASSWORD=RedisPass456!
APP_ENV=production
EOF

# Restrict file permissions
chmod 600 .env
Never commit the .env file to Git! Add it to .gitignore immediately.

Step 3 — Write docker-compose.yml

This is the heart of this article — the docker-compose.yml file that defines all services in one place:

cat > docker-compose.yml << 'EOF'
services:

  # ─── Web Server ──────────────────────────────────────
  nginx:
    image: nginx:1.25-alpine
    container_name: myapp_nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./app:/usr/share/nginx/html:ro
      - nginx_logs:/var/log/nginx
    depends_on:
      - postgres
      - redis
    networks:
      - frontend
      - backend

  # ─── Database ────────────────────────────────────────
  postgres:
    image: postgres:16-alpine
    container_name: myapp_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ─── Cache ───────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: myapp_redis
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

# ─── Networks ──────────────────────────────────────────
networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true   # This network has no direct internet access

# ─── Volumes ───────────────────────────────────────────
volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local
  nginx_logs:
    driver: local
EOF

Notable Elements in This Configuration

Step 4 — Run the Stack and Verify

# Run all services in the background (-d = detached mode)
docker compose up -d

# Watch logs in real-time (Ctrl+C to exit)
docker compose logs -f

# View logs for postgres only
docker compose logs -f postgres

# Check status of all containers
docker compose ps

Expected output from docker compose ps:

NAME               IMAGE                COMMAND                  STATUS
myapp_nginx        nginx:1.25-alpine    "/docker-entrypoint.…"   Up (healthy)
myapp_postgres     postgres:16-alpine   "docker-entrypoint.s…"   Up (healthy)
myapp_redis        redis:7-alpine       "docker-entrypoint.s…"   Up (healthy)
If STATUS shows Up (health: starting), wait a moment — the system is running healthchecks. If it takes longer than 2 minutes, check the logs for details.

Step 5 — Common Management Commands

Lifecycle Commands

# Stop all services (preserves data)
docker compose stop

# Stop and remove containers (preserves volumes)
docker compose down

# Stop and remove everything including volumes (warning: data loss)
docker compose down -v

# Restart a single service
docker compose restart nginx

# Reload configuration after editing docker-compose.yml
docker compose up -d --force-recreate nginx

Debugging and Executing Into Containers

# Enter shell of postgres container
docker compose exec postgres sh

# Run psql directly
docker compose exec postgres psql -U appuser -d myapp

# Test Redis
docker compose exec redis redis-cli -a RedisPass456! ping
# Should return PONG

# Monitor resource usage in real-time
docker stats myapp_nginx myapp_postgres myapp_redis

Update Images to Newer Versions

# Pull latest images for all services
docker compose pull

# Recreate containers with new images
docker compose up -d

# Remove old unused images
docker image prune -f

Step 6 — Back Up PostgreSQL Data

Data in volumes is protected from container recreation, but you should still back it up:

# Dump database to a .sql file
docker compose exec -T postgres \
  pg_dump -U appuser myapp \
  > /opt/backups/myapp_$(date +%Y%m%d_%H%M%S).sql

# Restore from backup
docker compose exec -T postgres \
  psql -U appuser myapp \
  < /opt/backups/myapp_20260603_120000.sql

Set Up Daily Cron Backup

# Edit crontab
crontab -e

# Add this line — backs up daily at 2 AM
0 2 * * * docker compose -f /opt/myapp/docker-compose.yml exec -T postgres \
  pg_dump -U appuser myapp \
  > /opt/backups/myapp_$(date +\%Y\%m\%d).sql 2>&1

Step 7 — Add SSL with Certbot (HTTPS)

For production, you need HTTPS. Use Certbot standalone:

# Temporarily stop Nginx so Certbot can use port 80
docker compose stop nginx

# Install Certbot
apt install -y certbot

# Request certificate
certbot certonly --standalone -d yourdomain.com -d www.yourdomain.com

# Certificate will be at:
# /etc/letsencrypt/live/yourdomain.com/fullchain.pem
# /etc/letsencrypt/live/yourdomain.com/privkey.pem

Then update nginx/default.conf to support HTTPS and mount certificates into the container:

# Add to nginx service volumes in docker-compose.yml
volumes:
  - /etc/letsencrypt:/etc/letsencrypt:ro
  - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
Don't forget to auto-renew certificates: certbot renew --pre-hook "docker compose -f /opt/myapp/docker-compose.yml stop nginx" --post-hook "docker compose -f /opt/myapp/docker-compose.yml start nginx"

Pre-Launch Checklist

  1. Verify all containers show healthy status in docker compose ps
  2. .env file has 600 permissions and is not in Git
  3. Backend network is set to internal: true to prevent database exposure
  4. Set restart: unless-stopped for all critical services
  5. Have a backup strategy for database volumes
  6. Set maxmemory for Redis to prevent RAM exhaustion
  7. Test docker compose down && docker compose up -d to ensure data persists

Summary

Docker Compose transforms managing multi-container applications from typing long, complex commands into using a single, readable, version-controlled YAML file. The key takeaways from this guide are:

When your project grows and you need to scale across multiple VPS instances, Docker Swarm or Kubernetes are the next steps. But for medium-sized applications running on a single VPS, Docker Compose covers all your needs comprehensively.

Verify Docker Compose Stack Is Working Correctly

# Check status of all containers
docker compose ps

# Correct output: all STATUS should be "Up (healthy)"
# NAME               STATUS
# myapp_nginx        Up (healthy)
# myapp_postgres     Up (healthy)
# myapp_redis        Up (healthy)

# Test postgres healthcheck
docker compose exec postgres pg_isready -U appuser -d myapp

# Test Redis
docker compose exec redis redis-cli -a RedisPass456! ping
# Should return PONG

# Check resource usage of each container
docker stats --no-stream myapp_nginx myapp_postgres myapp_redis
Tip: If STATUS shows Up (health: starting), wait 10-30 seconds for healthchecks to complete. If it changes to (unhealthy), run docker compose logs postgres to see the error.

Common Issues & Troubleshooting

Container exits immediately after start — restart loop

The application inside the container is crashing. Check the logs to find the cause.

# View logs of the problematic container
docker compose logs postgres

# Check exit code (Non-zero = error)
docker compose ps -a

# Run container interactively to debug
docker compose run --rm postgres sh
# Then run commands to see errors directly

Error: "port is already allocated" or "bind: address already in use"

Port 80/443/5432 is already in use by another application on the host.

# Check what's using port 80
sudo ss -tlnp | grep :80
# Or
sudo lsof -i :80

# If it's host-level Nginx
sudo systemctl stop nginx

# Or change port mapping in docker-compose.yml
ports:
  - "8080:80"   # Change from "80:80"

Database data disappeared after docker compose down

Using docker compose down -v deletes volumes. Use just docker compose down to preserve data.

# Check if volume still exists
docker volume ls | grep myapp

# If volume is lost, restore from backup
docker compose up -d postgres
docker compose exec -T postgres psql -U appuser myapp < backup.sql

# Rule: docker compose down = remove containers, keep volumes ✅
#       docker compose down -v = remove containers AND volumes ⚠️

.env file not being loaded — variables are empty

Docker Compose only reads .env from the same directory as docker-compose.yml.

# Verify .env is in the right place
ls -la /opt/myapp/.env

# Test that Compose reads .env
docker compose config | grep POSTGRES

# To use a different .env file
docker compose --env-file /path/to/.env up -d

Healthcheck constantly fails — container stuck on "unhealthy"

The healthcheck command may be wrong or the timeout too short for startup.

# View the latest healthcheck result
docker inspect myapp_postgres | python3 -m json.tool | grep -A10 Health

# Add start_period to give container time to initialize before healthchecking
healthcheck:
  test: ["CMD-SHELL", "pg_isready -U appuser"]
  interval: 10s
  timeout: 5s
  retries: 5
  start_period: 30s   # Wait 30 seconds before starting healthchecks

Frequently Asked Questions

What's the difference between docker-compose.yml and Dockerfile?

Dockerfile is a recipe for building an image of a single container — it specifies the base image, what packages to install, and what files to copy. docker-compose.yml is an orchestration file that defines what containers to run, what images to use, how to map ports and volumes, and which containers depend on each other. You use both together: Compose uses Dockerfile to build your app's image and pulls pre-built images (nginx, postgres, redis) directly.

What's the difference between depends_on and healthcheck?

By default, depends_on only waits for a container to start, not for the service inside to be ready. For example, Nginx might start before Postgres is ready to accept connections. For true readiness waiting, combine depends_on with condition: service_healthy, which requires a healthcheck in that service too.

When should I switch from Docker Compose to Kubernetes?

Docker Compose is ideal for single-server deployments. If you need to scale across multiple VPS instances, implement auto-scaling, perform zero-downtime rolling updates, or want sophisticated self-healing, consider Kubernetes or Docker Swarm. But for startups or projects not exceeding 10,000 requests/day, Compose is sufficient and much simpler.