Why Docker Compose Is the Unsung Hero of Self-Hosting
Here’s a scene you might recognize: you spun up a Docker container last Tuesday for a cute little self-hosted app — maybe it was Uptime Kuma, maybe it was a notes app — and it worked great. Then you rebooted the server. And it was gone. Not dead, just… absent. The container didn’t auto-start. Your neat little app is now a memory.
That’s the moment most people discover Docker Compose. And honestly? It changes everything.
Docker Compose lets you define your containers, their networks, their volumes, their restart policies — all in a single YAML file. No more remembering seventeen docker run flags. No more “did I map port 8080 or 8989?” moments. Just one file, one command, and your whole stack comes up.
Prerequisites
nano, vim, whatever)Step 1: Make Sure Docker and Docker Compose Are Installed
First, verify Docker is running:
sudo systemctl status docker
If it’s not running, start it and enable it on boot:
sudo systemctl enable --now docker
Now check if Docker Compose (v2 plugin) is available:
docker compose version
You should see something like Docker Compose version v2.x.x. If you get a “command not found,” install it:
sudo apt update && sudo apt install -y docker-compose-plugin
Step 2: Create Your Project Directory
Good practice: keep each app stack in its own folder. This keeps things tidy and makes backups straightforward.
mkdir -p ~/docker/myapp
cd ~/docker/myapp
Step 3: Write Your docker-compose.yml
This is where the magic lives. Create the file:
nano docker-compose.yml
Let’s use a real example: Uptime Kuma — a beautiful, self-hosted uptime monitor. Here’s the full config:
version: "3.8"
services:
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
ports:
- "3001:3001"
volumes:
- ./data:/app/data
restart: unless-stopped
Let’s break down what each line does, because the YAML structure matters:
docker compose commands.:1 pins to major version 1 — this prevents surprise breaking changes on update.myapp-uptime-kuma-1).HOST:CONTAINER. So port 3001 on your server forwards to 3001 inside the container../data:/app/data means “create a data/ folder next to this YAML file, mount it at /app/data inside the container.”unless-stopped means the container automatically restarts after crashes and on boot — unless you explicitly ran docker compose stop. This is the gold standard for self-hosted services.Step 4: Bring It Up
The -d flag runs everything in detached mode (background). Docker will pull the image if needed, create the container, and start it.
docker compose up -d
Open a browser to http://your-server-ip:3001 and you’ll see the setup screen. That’s it — your uptime monitor is running.
Step 5: Essential Commands You’ll Actually Use
Here are the commands you’ll reach for 90% of the time:
# See what's running
docker compose ps
# View logs (live, like tail -f)
docker compose logs -f
# View logs for just one service
docker compose logs -f uptime-kuma
# Stop everything (data is safe!)
docker compose stop
# Start it back up
docker compose start
# Pull new images and restart (for updates)
docker compose pull && docker compose up -d
# Take everything down and remove containers
docker compose down
# Take everything down AND remove data volumes
docker compose down -v
Notice that last one — docker compose down -v. That’s the nuclear option. It removes containers and their persistent data. Use it carefully, especially on production.
Multi-Service Stacks: Where Compose Really Shines
A single container is fine, but the real power is orchestrating multiple services that talk to each other. Here’s a more realistic example: a monitoring stack with Uptime Kuma, Grafana, and Prometheus.
version: "3.8"
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
ports:
- "9090:9090"
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: grafana
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=changeme
depends_on:
- prometheus
restart: unless-stopped
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
ports:
- "3001:3001"
volumes:
- uptime_data:/app/data
restart: unless-stopped
volumes:
prometheus_data:
grafana_data:
uptime_data:
Key things to notice here:
volumes: key): These are managed by Docker and persist even if you run docker compose down. Better than bind mounts for production.http://prometheus:9090 — no need to expose that port to your host! Only the services you ports: map are reachable from outside.Common Pitfalls and Troubleshooting
Port conflicts
If you get port is already allocated, something else is using that port. Check with:
sudo ss -tlnp | grep :3001
Either stop the conflicting service or change the host port in your YAML (e.g., "3002:3001").
Indentation matters
YAML is whitespace-sensitive. Use spaces (not tabs), and be consistent. A single misplaced indentation level will give you cryptic errors like service "x" refers to undefined volume. Most editors have YAML syntax highlighting — turn it on.
“Image not found” errors
Usually means the image name or tag doesn’t exist on Docker Hub. Double-check the spelling. Tag :latest can be unpredictable — prefer pinning specific versions like :1.25.0 for anything important.
Permission issues with volumes
If a container can’t write to its volume, check the host directory permissions:
ls -la ~/docker/myapp/data
Docker containers run as root by default, so the mounted directory needs to be writable. Most well-documented images handle this gracefully — but it’s a common gotcha on custom setups.
A Few Pro Tips
.env file to store secrets and configuration outside your YAML. Then reference them as ${DB_PASSWORD} in your compose file. Never commit secrets to git.docker compose (no hyphen) for Docker v2+. The old docker-compose (with hyphen) is deprecated.deploy.resources.limits.memory: 512M under your service.healthcheck directives so Docker knows when a container is actually ready (not just “the process started”).Wrapping Up
Docker Compose turns the chaos of managing individual containers into something declarative, repeatable, and shareable. One YAML file replaces pages of documentation. One command brings your whole stack up. And when you add restart: unless-stopped to everything? You get that beautiful feeling of setting something up and walking away, knowing it’ll still be there tomorrow.
Start small. One app, one compose file, one volume. Then add another service. Then another. Before you know it, you have a little homelab kingdom — all defined in a few cleanly indented files.
And hey — when you inevitably break something, docker compose down && docker compose up -d fixes most things in under ten seconds. That’s a kind of freedom.
Leave a Reply