DevOps

Docker Compose for Self-Hosting: A Complete Beginner's Guide

2026-06-16 · 15 min read · views

If you're getting into self-hosting, Docker Compose is the single most important tool you'll learn. It turns the messy chore of running multiple services — databases, web servers, caches, reverse proxies — into a single docker compose up -d command. According to Docker's 2024 Annual Survey, over 65% of Docker users rely on Compose in production workflows, and self-hosted infrastructure accounts for a growing share of that usage. This guide teaches you exactly what you need to get productive with Docker Compose, starting from zero.

My entire homelab stack runs on Docker Compose — Vaultwarden, Grafana, Prometheus, Nginx. I keep all compose files in a Git repo. The one thing I learned: always use named volumes, not bind mounts. I once ran docker compose down -v and lost a database.

What You'll Learn

1. What is Docker Compose?

Docker Compose is a tool for defining and running multi-container Docker applications. Instead of typing five separate docker run commands with long lists of flags, you write everything in a YAML file — one declarative configuration that describes every service, volume, network, and environment variable.

According to the Docker 2024 Annual Report, container adoption continues to accelerate — over 89% of developers now use containers in production, and Compose is the standard tool for orchestrating multi-container workloads at the self-hosted scale. The report also notes that Docker Hub surpassed 100 billion image pulls, reflecting the platform's massive growth in the DevOps ecosystem.

For self-hosters, this is a game changer. A typical self-hosted stack (app + database + cache + reverse proxy) can be reproduced, backed up, migrated, and shared as a single file. No more "I forgot which port I mapped" or fumbling through shell history.

# One command to start everything
docker compose up -d

# One command to tear everything down
docker compose down

2. Installation

2.1 Install Docker Engine

Docker Compose requires Docker first. On Ubuntu 24.04 or Debian 12, follow the official Docker installation guide:

# Install dependencies
sudo apt update && sudo apt install ca-certificates curl -y

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

# Add the repository
echo "deb [arch=$(dpkg --print-architecture) \
  signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io -y

# Add your user to the docker group (avoids sudo on every command)
sudo usermod -aG docker $USER
# Log out and back in for the group change to take effect

2.2 Install Docker Compose

Modern Docker includes Compose as a built-in plugin (docker compose, note the space, not a hyphen). Verify it:

docker compose version
# Expected output: Docker Compose version v2.32.x

If you see a version number, you're good. If not, install the plugin separately:

sudo apt install docker-compose-plugin -y

💡 Note: docker-compose vs docker compose

The legacy docker-compose (with hyphen, v1) is deprecated. Always use docker compose (space, v2) moving forward. All examples in this guide use the v2 syntax.

3. The docker-compose.yml File — Everything You Need

Every Compose project starts with a docker-compose.yml file (or compose.yaml — both work, but compose.yaml is the modern convention). Here's a minimal but complete example:

version: "3.9"    # ⬅ schema version (still needed for some features)

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    restart: unless-stopped

Let's break down the key sections:

Directive Purpose
services Defines each container (service) in the stack
image Which Docker image to pull and run
ports Map host port → container port ("HOST:CONTAINER")
volumes Persist data or mount files from host into the container
environment Set environment variables inside the container
depends_on Start order: wait for another service before starting
restart Restart policy (no, always, on-failure, unless-stopped)
networks Custom internal networks (containers can reach each other by service name)

⚡ Best Practice: Always Pin Image Versions

Using image: nginx:latest in production is a risk — a breaking update can silently brick your service on the next pull. Always pin to a specific version tag:

# Good — explicit version
image: postgres:16-alpine

# Bad — unpredictable
image: postgres:latest

When you want to upgrade, change the tag manually and run docker compose up -d — controlled, deliberate, safe.

4. Essential Docker Compose Commands

You'll use these commands daily. Learn them once, use them forever.

Start services

# Start in foreground (logs visible, Ctrl+C to stop)
docker compose up

# Start in background (daemon mode)
docker compose up -d

Stop services

# Stop containers without removing them
docker compose stop

# Stop and remove containers, networks, and default volumes
docker compose down

# Stop and remove everything including named volumes (⚠️ destroys data!)
docker compose down -v

View logs

# Follow logs from all services
docker compose logs -f

# Follow logs from one service
docker compose logs -f web

Rebuild and restart

# Rebuild images (when you change Dockerfile) then restart
docker compose up -d --build

# Restart a single service
docker compose restart web

Check status

# List running containers for this project
docker compose ps

# View resource usage
docker compose top

Execute commands in a running container

# Open a shell inside the web service
docker compose exec web sh

# Run a one-off command
docker compose exec db pg_dump -U postgres mydb > backup.sql

5. Real-World Example: Deploy a Flask App with PostgreSQL and Nginx

Let's build a complete self-hosted web application stack: a Python Flask API, a PostgreSQL database for persistent storage, and Nginx as a reverse proxy. This is a pattern you'll see in countless self-hosted deployments.

5.1 Project Structure

myflaskapp/
├── docker-compose.yml
├── Dockerfile
├── app/
│   ├── app.py
│   └── requirements.txt
└── nginx/
    └── default.conf

Create the project directory:

mkdir -p ~/myflaskapp/{app,nginx}
cd ~/myflaskapp

5.2 The Flask Application

# app/requirements.txt
flask==3.1.*
psycopg2-binary==2.9.*
gunicorn==23.*
# app/app.py
from flask import Flask, jsonify
import psycopg2
import os

app = Flask(__name__)

DB_HOST = os.getenv("DB_HOST", "db")
DB_NAME = os.getenv("DB_NAME", "myapp")
DB_USER = os.getenv("DB_USER", "myapp")
DB_PASS = os.getenv("DB_PASS", "changeme")

def get_db():
    return psycopg2.connect(
        host=DB_HOST, dbname=DB_NAME,
        user=DB_USER, password=DB_PASS
    )

@app.route("/")
def home():
    return jsonify({"message": "Hello from Flask on Docker Compose!"})

@app.route("/health")
def health():
    try:
        conn = get_db()
        cur = conn.cursor()
        cur.execute("SELECT 1")
        cur.close()
        conn.close()
        return jsonify({"status": "healthy", "database": "connected"})
    except Exception as e:
        return jsonify({"status": "unhealthy", "error": str(e)}), 500

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

5.3 The Dockerfile

# Dockerfile
FROM python:3.12-slim

WORKDIR /app
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ .

EXPOSE 5000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]

5.4 Nginx Configuration

# nginx/default.conf
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://web:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

5.5 The docker-compose.yml

Here's where it all comes together:

# docker-compose.yml
version: "3.9"

services:
  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: changeme
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

  web:
    build: .
    depends_on:
      db:
        condition: service_healthy
    environment:
      DB_HOST: db
      DB_NAME: myapp
      DB_USER: myapp
      DB_PASS: changeme
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - web
    restart: unless-stopped

volumes:
  pgdata:

5.6 Deploy It

# From the myflaskapp directory
docker compose up -d

# Check status
docker compose ps

# View logs
docker compose logs -f

# Test the API
curl http://localhost/
# {"message":"Hello from Flask on Docker Compose!"}

curl http://localhost/health
# {"status":"healthy","database":"connected"}

That's it. Three services — database, application, proxy — defined declaratively and running in seconds. To stop everything:

docker compose down

Your database data survives because it's stored in the named volume pgdata. To wipe it completely (including the database):

docker compose down -v

🔐 Security Tip: Environment Variables

Never hardcode passwords in docker-compose.yml. Use an .env file in the same directory:

# .env
DB_PASS=your-strong-password-here

Then reference it in compose as ${DB_PASS}. Add .env to your .gitignore if using version control.

6. Troubleshooting Common Issues

Port already in use

Error: port is already allocated. The host port is taken by another process or container.

# Find what's using the port
sudo lsof -i :80

# Or check which container has it
docker ps --format "table {{.Names}}\t{{.Ports}}" | grep 80

# Change the host port in docker-compose.yml — e.g., "8080:80" instead of "80:80"

Container keeps restarting

# Check the logs
docker compose logs --tail=50 web

# Common causes:
# - The application crashed on startup (check Python traceback)
# - Database wasn't ready (verify depends_on healthcheck)
# - Environment variables missing or wrong

Can't connect to database from my app

Within the same Compose project, services resolve each other by service name. The connection string should use the service name (db, not localhost).

# ❌ Wrong — localhost refers to the container itself, not the db container
DB_HOST=localhost

# ✅ Correct — Compose DNS resolves "db" to the db container's IP
DB_HOST=db

Permission denied on volume mounts

Host files mounted into containers inherit host permissions. If Nginx can't read your config:

# Fix file permissions on the host
chmod 644 nginx/default.conf

# Or run the container with a specific user (check the image docs)

Changes to code not reflected

If you update app.py but see the old version running:

# You need to rebuild the image and restart
docker compose up -d --build web

For development, consider using a bind mount instead of rebuilding every time — but for production, always rebuild for consistency.

7. Summary

Docker Compose transforms self-hosting from "a collection of fragile shell scripts" into "a single, version-controlled YAML file." You now know how to:

According to Docker's 2024 survey, 89% of developers now use containers in production, and Compose is the standard tool for orchestrating multi-container workloads at the self-hosted scale. Whether you're running a personal blog, a home media server, or a side-project API, Docker Compose gives you repeatability and confidence. The next time you start a fresh VPS, you'll be up and running in minutes — not hours.

FAQ

How do I use Docker Compose with multiple environments (dev, staging, production)?

Use multiple Compose files: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d. The second file overrides settings from the first. You can define environment-specific variables using .env files and reference them with ${VAR_NAME} syntax in your Compose YAML.

What is the difference between docker compose up -d and docker compose start?

docker compose up -d creates and starts containers (including building images if needed, recreating existing containers). docker compose start only restarts existing stopped containers without recreating them. Use up for initial deployment and after config changes; use start after a stop.

How do I view logs for a specific service over time?

Use docker compose logs -f --tail=100 service_name to follow logs with the last 100 lines. For persistent log storage, configure a logging driver (like the local or journald driver) in docker-compose.yml under the service's logging section.

Can I use Docker Compose with Docker Swarm?

Docker Compose files (v3.8+) are compatible with Docker Swarm. You can deploy the same Compose file to a Swarm cluster using docker stack deploy -c docker-compose.yml myapp. However, some Compose features (like depends_on with healthchecks) behave differently in Swarm mode.

How do I backup volumes managed by Docker Compose?

Use a temporary sidecar container: docker run --rm -v myvolume:/data -v $(pwd):/backup alpine tar -czf /backup/volume-backup.tar.gz -C /data .. Restore with: docker run --rm -v myvolume:/data -v $(pwd):/backup alpine tar -xzf /backup/volume-backup.tar.gz -C /data. Schedule these commands in cron for regular backups.

← Back to Home

Related Articles

About the Author

Frankie is a self-hosting enthusiast and DevOps practitioner with over 8 years of experience deploying and maintaining production Linux servers, Docker environments, and homelab infrastructure. He writes practical, hands-on guides to help others take control of their own infrastructure.