BlogTutorialsDocker Compose on VPS: Production Setup in 15 Minutes

Docker Compose on VPS: Production Setup in 15 Minutes

Adrian Silaghi
Adrian Silaghi
December 9, 2025
10 min read
110 views
#docker #docker-compose #vps #deployment #containers #devops

Docker Compose turns multi-container applications into single-command deployments. This guide shows you how to go from fresh VPS to production-ready Docker environment in 15 minutes.

Why Docker Compose for Production?

Benefits

  • Consistency: Same environment in development and production
  • Isolation: Each service runs in its own container
  • Easy updates: Pull new image, restart container
  • Version control: Infrastructure as code in docker-compose.yml
  • Quick rollbacks: Keep old images for instant recovery

When to Use Docker Compose

  • Multi-service applications (app + database + cache + queue)
  • Microservices architecture
  • Running multiple isolated applications on one VPS
  • Development/production parity

Prerequisites

What You Need

  • VPS with Ubuntu 22.04 or 24.04
  • 2GB RAM minimum (4GB recommended)
  • SSH access with sudo privileges
  • Domain name pointed to VPS IP (for SSL)

Quick start: Deploy a 2-core, 4GB DanubeData VPS in Frankfurt for $8.99/month. Comes with 80GB NVMe storage.

Step 1: Install Docker (3 minutes)

SSH into your VPS and run:

# Update system
sudo apt update && sudo apt upgrade -y

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Add your user to docker group
sudo usermod -aG docker $USER

# Install Docker Compose V2
sudo apt install docker-compose-plugin

# Verify installation
docker --version
docker compose version

# Log out and back in for group changes to take effect
exit

Expected output:

Docker version 25.0.0
Docker Compose version v2.24.0

Step 2: Create Project Structure (2 minutes)

# Create project directory
mkdir -p ~/myapp && cd ~/myapp

# Create subdirectories
mkdir -p nginx/conf.d nginx/ssl data/mysql data/redis

Your structure:

~/myapp/
├── docker-compose.yml
├── .env
├── nginx/
│   ├── conf.d/
│   │   └── app.conf
│   └── ssl/
├── data/
│   ├── mysql/
│   └── redis/
└── app/  (your application code)

Step 3: Create docker-compose.yml (3 minutes)

Create docker-compose.yml:

version: '3.8'

services:
  # Nginx Reverse Proxy
  nginx:
    image: nginx:alpine
    container_name: nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/ssl:/etc/nginx/ssl
      - ./app:/var/www/html
    networks:
      - app-network
    depends_on:
      - php

  # PHP Application (Laravel/Symfony/etc)
  php:
    image: php:8.3-fpm-alpine
    container_name: php-fpm
    restart: unless-stopped
    working_dir: /var/www/html
    volumes:
      - ./app:/var/www/html
    environment:
      - DB_HOST=mysql
      - DB_DATABASE=${DB_DATABASE}
      - DB_USERNAME=${DB_USERNAME}
      - DB_PASSWORD=${DB_PASSWORD}
      - REDIS_HOST=redis
    networks:
      - app-network
    depends_on:
      - mysql
      - redis

  # MySQL Database
  mysql:
    image: mysql:8.0
    container_name: mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - ./data/mysql:/var/lib/mysql
    networks:
      - app-network
    command: --default-authentication-plugin=mysql_native_password

  # Redis Cache
  redis:
    image: redis:7-alpine
    container_name: redis
    restart: unless-stopped
    volumes:
      - ./data/redis:/data
    networks:
      - app-network
    command: redis-server --appendonly yes

networks:
  app-network:
    driver: bridge

volumes:
  mysql-data:
  redis-data:

Step 4: Configure Environment Variables (1 minute)

Create .env file:

# Database Configuration
DB_DATABASE=myapp
DB_USERNAME=myapp_user
DB_PASSWORD=change_this_secure_password
MYSQL_ROOT_PASSWORD=change_this_root_password

# Application
APP_ENV=production
APP_URL=https://myapp.com

Security tip: Generate strong passwords:

openssl rand -base64 32

Step 5: Configure Nginx (3 minutes)

Create nginx/conf.d/app.conf:

server {
    listen 80;
    server_name myapp.com www.myapp.com;

    # Redirect HTTP to HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name myapp.com www.myapp.com;

    root /var/www/html/public;
    index index.php index.html;

    # SSL Configuration (we'll add certificates next)
    ssl_certificate /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ .php$ {
        fastcgi_pass php:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /.(?!well-known).* {
        deny all;
    }

    # Cache static assets
    location ~* .(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Step 6: Setup SSL with Let's Encrypt (2 minutes)

# Install Certbot
sudo apt install certbot

# Stop containers temporarily
docker compose down

# Get SSL certificate
sudo certbot certonly --standalone -d myapp.com -d www.myapp.com

# Copy certificates to project
sudo cp /etc/letsencrypt/live/myapp.com/fullchain.pem ~/myapp/nginx/ssl/
sudo cp /etc/letsencrypt/live/myapp.com/privkey.pem ~/myapp/nginx/ssl/

# Fix permissions
sudo chown $USER:$USER ~/myapp/nginx/ssl/*

Auto-Renewal Setup

# Create renewal hook
sudo nano /etc/letsencrypt/renewal-hooks/deploy/01-docker-reload.sh

Add:

#!/bin/bash
cp /etc/letsencrypt/live/myapp.com/fullchain.pem /home/ubuntu/myapp/nginx/ssl/
cp /etc/letsencrypt/live/myapp.com/privkey.pem /home/ubuntu/myapp/nginx/ssl/
docker exec nginx nginx -s reload
# Make executable
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/01-docker-reload.sh

# Test renewal (dry run)
sudo certbot renew --dry-run

Step 7: Launch Your Stack (1 minute)

# Start all services
docker compose up -d

# Verify everything is running
docker compose ps

Expected output:

NAME       SERVICE   STATUS    PORTS
nginx      nginx     running   0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
php-fpm    php       running   9000/tcp
mysql      mysql     running   3306/tcp
redis      redis     running   6379/tcp

Your application is now live! Visit https://myapp.com

Essential Docker Compose Commands

Daily Operations

# View logs
docker compose logs -f

# View logs for specific service
docker compose logs -f php

# Restart all services
docker compose restart

# Restart specific service
docker compose restart nginx

# Stop all services
docker compose down

# Stop and remove volumes (WARNING: deletes data)
docker compose down -v

# View resource usage
docker stats

Updating Your Application

# Pull latest code
cd ~/myapp/app
git pull origin main

# Rebuild and restart (if needed)
docker compose up -d --build

# Or just restart PHP-FPM
docker compose restart php

Database Management

# Access MySQL shell
docker compose exec mysql mysql -u root -p

# Run MySQL commands from host
docker compose exec mysql mysql -u${DB_USERNAME} -p${DB_PASSWORD} ${DB_DATABASE} -e "SHOW TABLES;"

# Backup database
docker compose exec mysql mysqldump -u${DB_USERNAME} -p${DB_PASSWORD} ${DB_DATABASE} > backup.sql

# Restore database
docker compose exec -T mysql mysql -u${DB_USERNAME} -p${DB_PASSWORD} ${DB_DATABASE} < backup.sql

Debugging

# Execute command in container
docker compose exec php php -v

# Access container shell
docker compose exec php sh

# View container details
docker inspect php-fpm

# Check network connectivity
docker compose exec php ping mysql

Production Best Practices

1. Resource Limits

Prevent containers from consuming all resources:

services:
  php:
    image: php:8.3-fpm-alpine
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 1G
        reservations:
          cpus: '0.5'
          memory: 512M

2. Health Checks

services:
  mysql:
    image: mysql:8.0
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s

3. Logging Configuration

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

4. Security Hardening

services:
  mysql:
    image: mysql:8.0
    security_opt:
      - no-new-privileges:true
    read_only: true
    tmpfs:
      - /tmp
      - /var/run/mysqld

Real-World Example: Laravel Application

Complete docker-compose.yml for Laravel:

version: '3.8'

services:
  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/ssl:/etc/nginx/ssl
      - ./laravel:/var/www/html
    networks:
      - laravel
    depends_on:
      - php

  php:
    build:
      context: ./docker/php
      dockerfile: Dockerfile
    restart: unless-stopped
    volumes:
      - ./laravel:/var/www/html
    environment:
      DB_HOST: mysql
      REDIS_HOST: redis
    networks:
      - laravel

  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: laravel
      MYSQL_USER: laravel
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mysql-data:/var/lib/mysql
    networks:
      - laravel

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis-data:/data
    networks:
      - laravel

  queue:
    build:
      context: ./docker/php
      dockerfile: Dockerfile
    restart: unless-stopped
    command: php artisan queue:work --sleep=3 --tries=3
    volumes:
      - ./laravel:/var/www/html
    networks:
      - laravel
    depends_on:
      - mysql
      - redis

  scheduler:
    build:
      context: ./docker/php
      dockerfile: Dockerfile
    restart: unless-stopped
    command: /bin/sh -c "while true; do php artisan schedule:run --verbose --no-interaction & sleep 60; done"
    volumes:
      - ./laravel:/var/www/html
    networks:
      - laravel

networks:
  laravel:
    driver: bridge

volumes:
  mysql-data:
  redis-data:

Monitoring Your Docker Stack

Container Statistics

# Real-time stats
docker stats

# JSON output for parsing
docker stats --no-stream --format "{{json .}}"

Setup Automated Monitoring

Add Prometheus + Grafana to docker-compose.yml:

  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana
    volumes:
      - grafana-data:/var/lib/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin

Backup Strategy

Automated Daily Backups

Create backup.sh:

#!/bin/bash
BACKUP_DIR="/home/ubuntu/backups"
DATE=$(date +%Y%m%d_%H%M%S)

# Backup MySQL
docker compose exec -T mysql mysqldump -uroot -p${MYSQL_ROOT_PASSWORD} --all-databases > $BACKUP_DIR/mysql_$DATE.sql

# Backup Redis
docker compose exec redis redis-cli BGSAVE
cp ~/myapp/data/redis/dump.rdb $BACKUP_DIR/redis_$DATE.rdb

# Backup application files
tar -czf $BACKUP_DIR/app_$DATE.tar.gz ~/myapp/app

# Delete backups older than 7 days
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete
find $BACKUP_DIR -name "*.rdb" -mtime +7 -delete
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete

echo "Backup completed: $DATE"

Schedule with cron:

crontab -e

# Add daily backup at 2 AM
0 2 * * * /home/ubuntu/myapp/backup.sh

Troubleshooting Common Issues

Issue Cause Solution
Container keeps restarting Application crash docker compose logs [service]
Port already in use Another service on port 80/443 sudo lsof -i :80 and stop conflicting service
Permission denied Volume ownership mismatch sudo chown -R $USER:$USER ./data
Can't connect to MySQL Network isolation Use service name (mysql) not localhost
Out of disk space Old images/containers docker system prune -a

Performance Optimization

1. Use Multi-Stage Builds

# Dockerfile for PHP
FROM composer:latest AS composer
WORKDIR /app
COPY composer.* ./
RUN composer install --no-dev --optimize-autoloader

FROM php:8.3-fpm-alpine
WORKDIR /var/www/html
COPY --from=composer /app/vendor ./vendor
COPY . .

2. Enable OPcache

# php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

3. Use Alpine Images

Alpine images are 5-10x smaller:

  • nginx:alpine - 40MB vs 180MB
  • php:8.3-fpm-alpine - 80MB vs 450MB
  • redis:alpine - 30MB vs 120MB

Why DanubeData for Docker Hosting?

Specification DanubeData VPS DigitalOcean Linode
2 vCPU, 4GB RAM $8.99/mo $24/mo $24/mo
Storage 80GB NVMe 80GB SSD 80GB SSD
Transfer 20TB 4TB 4TB
Backup Storage Included $8/mo extra $5/mo extra
Setup Time < 60 seconds ~2 minutes ~2 minutes
Location Germany (GDPR) Global Global

Get Started Now

  1. Create your DanubeData account
  2. Deploy a 2-core, 4GB VPS (Ubuntu 24.04)
  3. Follow this guide to install Docker Compose
  4. Launch your containerized application
  5. Add managed database and Redis for optimal performance

Perfect for: Web applications, APIs, microservices, WordPress, Laravel, Node.js, Python, and any multi-container setup.

Questions about Docker hosting? Contact our team or check our documentation for more guides.

Share this article

Ready to Get Started?

Deploy your infrastructure in minutes with DanubeData's managed services.