Tutorial

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-compose binary)
  • 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:

  1. Pin versions. louislam/uptime-kuma:1.23, not latest. latest is how you wake up to a broken stack after an upgrade you didn’t choose.
  2. Named volumes for data, bind mounts for config. Databases and app data go in named volumes (managed by Docker). Your own Caddyfile or .env goes in bind mounts (you control it).
  3. depends_on is 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

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.

Henry Xiang

I keep self-hosted services alive for a living — backups, docs, monitoring, the boring parts that break at 3am. Everything here runs on my own hardware first.