Self-Hosted Services with Docker Compose

Practical patterns for running multiple containers with proper isolation and a reverse proxy.

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.