How to Deploy a Self-Hosted App Stack with Docker Compose (2026)
Why this matters
Copy-pasting docker run commands from GitHub READMEs is fine for one app. It falls apart at five apps, when you realize they all want port 80, none of them can talk to each other, and your configs are scattered across three directories.
Docker Compose is how you stop juggling and start managing. One file, one command, everything starts and stops together.
What I tested on
- OS: Debian 13 (trixie)
- Docker: 28.x
- Docker Compose: v2.27.x (plugin, not the old
docker-composebinary) - Hardware: 4 GB RAM VPS + a Raspberry Pi 5 for the lightweight stuff
- Duration: this layout has been running for about 10 months
⚠️ Don’t run any of this on a public server without a firewall and a reverse proxy in front. Ports 80/443 should be the only ones exposed.
How I tested
I didn’t follow a tutorial. I migrated an existing five-app mess into one compose file, broke it twice, and wrote down what broke so you don’t have to.
Step 1: Install Docker and Compose
# Install Docker's official repo (Debian/Ubuntu)
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Verify
docker compose version
Add your user to the docker group so you don’t need sudo every time:
sudo usermod -aG docker $USER
newgrp docker
Step 2: The compose file structure
Here’s the layout I actually use:
# /srv/homelab/compose.yaml
services:
caddy:
image: caddy:2.8
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
uptime-kuma:
image: louislam/uptime-kuma:1.23
container_name: uptime-kuma
restart: unless-stopped
volumes:
- ./uptime-kuma/data:/app/data
depends_on: [caddy]
volumes:
caddy_data:
caddy_config:
Three things to notice:
- Pin versions.
louislam/uptime-kuma:1.23, notlatest.latestis how you wake up to a broken stack after an upgrade you didn’t choose. - Named volumes for data, bind mounts for config. Databases and app data go in named volumes (managed by Docker). Your own Caddyfile or
.envgoes in bind mounts (you control it). depends_onis about startup order, not readiness. It won’t wait for the app to actually be healthy.
Step 3: Keep your .env out of git
Secrets in compose files are a classic mistake. Use a .env file:
# .env — never commit this
POSTGRES_PASSWORD=use-a-real-password-not-this
SECRET_KEY=openssl rand -hex 32
TZ=Europe/London
# compose.yaml
services:
app:
image: myapp:latest
env_file: .env
Then block it in git:
echo ".env" >> .gitignore
Step 4: One reverse proxy for everything
This is the part most guides skip. You don’t expose port 3000 for every app — you put Caddy (or Nginx) in front and route by hostname:
# Caddyfile
app1.example.com {
reverse_proxy uptime-kuma:3001
}
app2.example.com {
reverse_proxy paperless:8000
}
Caddy handles HTTPS automatically via Let’s Encrypt. That single feature is why I use it over Nginx for small stacks.
Step 5: Updating safely
# Pull new images
docker compose pull
# Recreate containers with new images
docker compose up -d
# Clean up old images
docker image prune
Do this per-service if you’re nervous: docker compose up -d --no-deps uptime-kuma.
How to actually verify it works
Run docker compose ps and check every service says “running (healthy)” — the “healthy” part only appears if you defined a healthcheck. Add one:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 3
If a service isn’t healthy, docker compose logs -f servicename is where the answer lives.
Two files sit outside this one and matter as much as it does: the proxy that terminates TLS, covered in the Caddy walkthrough, and the job that copies these volumes somewhere else, covered in the restic backup guide. A tidy compose file with no backup is just an organised way to lose data.
Pros and cons
Pros:
- One command starts the whole stack
- Reproducible — the same file runs on your laptop and your server
- Easy rollback:
docker compose down && git checkout HEAD~1 && docker compose up -d
Cons:
- Networking is the #1 source of confusion for beginners
- Upgrading a database container can wipe data if you didn’t read the release notes
- Debugging “why is this container restarting” takes practice
Best for / Skip if
Best for: anyone running more than two self-hosted services.
Skip if: you have exactly one app and no plans to add more. Just run it directly and save yourself the abstraction.
Alternatives
- Kubernetes / k3s — if you have more than, say, 15 services and a second server. Overkill for a home box.
- Podman Compose — daemonless, rootless, more secure. A valid choice if you care about that.
- Just running binaries — still the lightest option. I do this for one or two small tools.
Verdict
Docker Compose is the right answer for a home server because it’s the smallest abstraction that still gives you reproducibility. The learning curve is real — the networking and the volumes are where everyone gets stuck — but it pays for itself the first time you migrate servers in an afternoon instead of a weekend.
References
- Docker Compose documentation — compose file reference and networking
- Compose CLI reference — commands used in this guide
FAQ
Should I put everything in one compose file?
No. Split by concern — `compose.caddy.yaml`, `compose.monitoring.yaml`, `compose.apps.yaml`. It limits blast radius when something breaks.
Can containers on different compose files talk to each other?
Only if they share a network. Either put them in the same file, or create an external network: `docker network create shared` and add `networks: [shared]` to each service.
How do I backup a Docker volume?
`docker run --rm -v myvolume:/data -v $(pwd):/backup alpine tar czf /backup/myvolume.tar.gz -C /data .`
Is it bad to run everything as root?
Yes. Check whether the image supports a non-root user (look for a `USER` directive or a `PUID`/`PGID` env var). If it does, use it.