Building a Docker Compose Stack for a Privacy-Focused Home Server
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
Every app you rely on a third-party to host is a privacy decision you've handed to someone else. Switching to a self-hosted stack isn't about paranoia — it's about ownership. In this tutorial I'll walk you through a production-ready Docker Compose configuration that bundles Vaultwarden (password manager), AdGuard Home (DNS-level ad blocking), Nextcloud (file sync), and Caddy (reverse proxy with automatic HTTPS) into a single, cohesive stack you can stand up in under an hour.
I've been running a variation of this stack on a small home server for over a year, and the approach I outline below reflects lessons learned the hard way — including the networking pitfalls that catch everyone out the first time.
What You'll Need Before You Start
This tutorial assumes you have a machine running Ubuntu 24.04 LTS (or Debian 12) with Docker and Docker Compose v2 already installed. You'll also need a domain name pointing at your server — either a public domain with a Cloudflare tunnel in front, or a private domain resolved via your own DNS. If you're running this on a VPS rather than bare metal at home, DigitalOcean Droplets are a solid, affordable choice for this kind of workload:
You'll need ports 80 and 443 accessible on the host (for Caddy), and port 3000 for AdGuard Home's initial setup web interface. I recommend having UFW configured with a default-deny policy before you expose anything.
Directory Structure and Environment Variables
Good discipline here saves hours of debugging later. I keep everything under /opt/homeserver on the host, with one directory per service for persistent data. Create the layout first:
sudo mkdir -p /opt/homeserver/{caddy/{config,data},vaultwarden/data,adguard/{work,conf},nextcloud/{data,config,apps},postgres}
# Create a dedicated non-root user to own the stack
sudo useradd -r -s /bin/false homeserver
sudo chown -R homeserver:homeserver /opt/homeserver
# Create a .env file for secrets — keep this out of version control
cat > /opt/homeserver/.env << 'EOF'
DOMAIN=yourdomain.com
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=changeme_strong_password
POSTGRES_USER=nextcloud
POSTGRES_PASSWORD=changeme_db_password
POSTGRES_DB=nextcloud
EOF
chmod 600 /opt/homeserver/.env
.env file to a public Git repository. Add it to .gitignore immediately if you're version-controlling your stack config. Rotate any credentials that get accidentally exposed — there are bots scanning GitHub for exactly this kind of leak within minutes of a push.The Docker Compose File
Here's the complete docker-compose.yml. I've opted for named volumes for Caddy's certificate storage because losing ACME state is a real pain, and bind mounts for service data because it makes backups with rsync trivially simple. All services sit on an isolated bridge network called proxy — only Caddy has ports bound to the host.
# /opt/homeserver/docker-compose.yml
# Docker Compose v2 — run with: docker compose up -d
networks:
proxy:
driver: bridge
internal: false
backend:
driver: bridge
internal: true # No direct internet access for DB + app layer
volumes:
caddy_data:
caddy_config:
services:
# --- Caddy: Automatic HTTPS reverse proxy ---
caddy:
image: caddy:2.8-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 support
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
env_file: .env
# --- Vaultwarden: Self-hosted Bitwarden-compatible password manager ---
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
volumes:
- ./vaultwarden/data:/data
environment:
- WEBSOCKET_ENABLED=true
- SIGNUPS_ALLOWED=false # Lock down after first account creation
- DOMAIN=https://vault.${DOMAIN}
networks:
- proxy
# No ports exposed to host — Caddy handles external access
# --- AdGuard Home: Network-wide DNS ad blocking ---
adguard:
image: adguard/adguardhome:latest
container_name: adguard
restart: unless-stopped
ports:
- "53:53/tcp"
- "53:53/udp"
- "3000:3000/tcp" # Initial setup only — disable after setup
volumes:
- ./adguard/work:/opt/adguardhome/work
- ./adguard/conf:/opt/adguardhome/conf
networks:
- proxy
# --- PostgreSQL: Database backend for Nextcloud ---
postgres:
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
volumes:
- ./postgres:/var/lib/postgresql/data
env_file: .env
networks:
- backend
# Intentionally on backend-only network, not reachable from proxy
# --- Nextcloud: Self-hosted file sync and collaboration ---
nextcloud:
image: nextcloud:29-apache
container_name: nextcloud
restart: unless-stopped
depends_on:
- postgres
volumes:
- ./nextcloud/data:/var/www/html/data
- ./nextcloud/config:/var/www/html/config
- ./nextcloud/apps:/var/www/html/custom_apps
environment:
- POSTGRES_HOST=postgres
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER}
- NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD}
- NEXTCLOUD_TRUSTED_DOMAINS=cloud.${DOMAIN}
- OVERWRITEPROTOCOL=https
networks:
- proxy
- backend # Needs both: proxy for Caddy, backend for Postgres
Configuring Caddy as the Front Door
I prefer Caddy over Nginx Proxy Manager for a stack like this because the Caddyfile syntax is genuinely readable, and it handles certificate renewal without any intervention at all. Create /opt/homeserver/caddy/Caddyfile:
# /opt/homeserver/caddy/Caddyfile
# Global options
{
email [email protected]
# Uncomment below for testing to avoid Let's Encrypt rate limits:
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
# Vaultwarden — password manager
vault.{$DOMAIN} {
encode gzip
# WebSocket notifications endpoint
reverse_proxy /notifications/hub vaultwarden:3012
reverse_proxy vaultwarden:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
# Nextcloud
cloud.{$DOMAIN} {
encode gzip
reverse_proxy nextcloud:80
# Required for Nextcloud's CalDAV/CardDAV redirects
rewrite /.well-known/carddav /remote.php/dav
rewrite /.well-known/caldav /remote.php/dav
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
}
}
# AdGuard Home web UI — restrict access if possible
dns.{$DOMAIN} {
reverse_proxy adguard:3000
# Consider adding basic_auth here for an extra layer
}
Bringing the Stack Up
With everything in place, start the stack from the /opt/homeserver directory:
cd /opt/homeserver
# Pull all images first so the initial start is faster
docker compose pull
# Start everything in detached mode
docker compose up -d
# Watch the logs to catch any early errors
docker compose logs -f --tail=50
# Check container health after a minute
docker compose ps
AdGuard Home needs a one-time web setup on port 3000. Navigate to http://your-server-ip:3000, complete the wizard, then go back and comment out the port 3000 mapping in your compose file and run docker compose up -d adguard to apply the change. Leave port 53 exposed — that's your DNS listener.
Post-Setup: Vaultwarden Account Lockdown
After creating your first Vaultwarden account, set SIGNUPS_ALLOWED=false in the compose environment block (it's already in the example above) and restart the container with docker compose restart vaultwarden. This prevents anyone who discovers your instance from registering. You can re-enable signups temporarily whenever you want to add a family member.
Keeping the Stack Updated
I don't recommend using :latest tags in production without a plan for updates. I use Watchtower in monitor-only mode to notify me of available updates, then update manually with:
# Pull updated images
docker compose pull
# Recreate only containers with new images (zero-downtime for others)
docker compose up -d --remove-orphans
# Clean up old image layers
docker image prune -f
Schedule this as a cron job or run it manually on a regular cadence — I do it every Sunday morning. For Nextcloud especially, always check the changelog before upgrading, as major version jumps require sequential updates through intermediate versions.
A Note on Running This on a VPS
If your home internet connection is unreliable or you don't have a static IP, hosting this stack on a VPS is a practical alternative. Start spending more time on your projects and less time managing your infrastructure. Create your DigitalOcean account today. A $6/month Droplet with 1 vCPU and 1 GB RAM comfortably runs Vaultwarden and AdGuard Home; add another $6 tier if you want Nextcloud in the mix.
What's Next
This stack gives you a solid privacy foundation: your passwords are out of the cloud, your DNS queries are filtered locally, and your files are yours. From here, the natural next step is adding Tailscale to the mix so you can access everything securely from anywhere without exposing any ports beyond 443. Check out our tutorial on WireGuard VPN for remote homelab access for the same concept if you prefer a self-hosted VPN approach.
You might also consider layering Authelia in front of sensitive services for two-factor authentication — particularly useful for the AdGuard Home UI and any other admin panels in the stack. The combination of Caddy + Authelia + Tailscale is, in my opinion, the privacy-respecting sweet spot for a home server in 2026.
Discussion