Docker Compose is the pragmatic way to manage a collection of self-hosted
services on a single host. A compose.yml file declares which
containers run, what ports they bind, which volumes persist their data, and
how containers can talk to each other. This guide covers a production-ish
Compose stack with three common services, proper network segmentation, and a
Caddy reverse proxy that handles TLS automatically.
1. Install Docker Engine
Use the official Docker repository rather than the Debian/Ubuntu package, which is often several releases behind:
apt install -y ca-certificates curl install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin systemctl enable --now docker docker version
2. Directory layout
Keep each service under its own data directory and your Compose files in a predictable location:
/srv/
compose/
main/
compose.yml
data/
nextcloud/
jellyfin/
vaultwarden/
caddy/
mkdir -p /srv/compose/main
mkdir -p /srv/data/{nextcloud,jellyfin,vaultwarden,caddy/{data,config}}
3. The Compose file
Create /srv/compose/main/compose.yml:
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /srv/data/caddy/data:/data
- /srv/data/caddy/config:/config
networks:
- proxy
nextcloud:
image: nextcloud:28-apache
restart: unless-stopped
depends_on:
- nextcloud-db
- nextcloud-redis
environment:
MYSQL_HOST: nextcloud-db
MYSQL_DATABASE: nextcloud
MYSQL_USER: ncuser
MYSQL_PASSWORD_FILE: /run/secrets/nextcloud_db_pw
NEXTCLOUD_TRUSTED_DOMAINS: files.home.lan
REDIS_HOST: nextcloud-redis
volumes:
- /srv/data/nextcloud:/var/www/html
secrets:
- nextcloud_db_pw
networks:
- proxy
- nextcloud-internal
nextcloud-db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/nextcloud_db_root_pw
MARIADB_DATABASE: nextcloud
MARIADB_USER: ncuser
MARIADB_PASSWORD_FILE: /run/secrets/nextcloud_db_pw
volumes:
- nc-db:/var/lib/mysql
secrets:
- nextcloud_db_pw
- nextcloud_db_root_pw
networks:
- nextcloud-internal
nextcloud-redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- nextcloud-internal
jellyfin:
image: jellyfin/jellyfin:latest
restart: unless-stopped
environment:
JELLYFIN_PublishedServerUrl: https://media.home.lan
volumes:
- /srv/data/jellyfin:/config
- /mnt/media:/media:ro
networks:
- proxy
vaultwarden:
image: vaultwarden/server:latest
restart: unless-stopped
environment:
DOMAIN: https://vault.home.lan
SIGNUPS_ALLOWED: "false"
ADMIN_TOKEN_FILE: /run/secrets/vaultwarden_admin
volumes:
- /srv/data/vaultwarden:/data
secrets:
- vaultwarden_admin
networks:
- proxy
networks:
proxy:
nextcloud-internal:
internal: true # no outbound internet access for DB/Redis
volumes:
nc-db:
secrets:
nextcloud_db_pw:
file: ./secrets/nextcloud_db_pw.txt
nextcloud_db_root_pw:
file: ./secrets/nextcloud_db_root_pw.txt
vaultwarden_admin:
file: ./secrets/vaultwarden_admin.txt
The nextcloud-internal network has internal: true,
which prevents the MariaDB and Redis containers from making outbound internet
connections. Only containers on the proxy network can reach the
internet.
4. Secrets
Create the secrets directory and populate it before starting the stack:
mkdir -p /srv/compose/main/secrets chmod 700 /srv/compose/main/secrets openssl rand -base64 32 | tr -d '\n' > /srv/compose/main/secrets/nextcloud_db_pw.txt openssl rand -base64 32 | tr -d '\n' > /srv/compose/main/secrets/nextcloud_db_root_pw.txt openssl rand -base64 48 | tr -d '\n' > /srv/compose/main/secrets/vaultwarden_admin.txt chmod 600 /srv/compose/main/secrets/*.txt
5. Caddyfile
Create /srv/compose/main/Caddyfile:
files.home.lan {
reverse_proxy nextcloud:80
}
media.home.lan {
reverse_proxy jellyfin:8096
}
vault.home.lan {
reverse_proxy vaultwarden:80
}
Caddy will obtain Let’s Encrypt certificates automatically if these
hostnames are publicly resolvable. For internal-only names, use Caddy’s
DNS-01 challenge with a supported DNS provider plugin, or use a self-signed
CA with tls internal.
6. Start the stack
cd /srv/compose/main docker compose up -d docker compose ps docker compose logs -f
7. Maintenance operations
# Pull updated images and recreate changed containers docker compose pull docker compose up -d --remove-orphans # Run a one-off command in a container docker compose exec nextcloud php occ status # View resource usage docker stats # Remove unused images docker image prune -f
8. Keeping data safe
Named volumes and bind mounts under /srv/data are the source of
truth for your services’ data. Include them in your backup strategy.
See the backup guide for how to
back up Docker volumes with restic.