Originally written for r/selfhosted on Reddit — sharing here for the dev.to community.
After running my self-hosted setup for 2+ years on a single Hetzner CX32 (4 vCPU, 8GB RAM, €15/mo), I finally cleaned up my config into something reusable. Currently hosting 18+ containers with ~1.7GB RAM to spare. Sharing the full setup in case it helps someone getting started or optimizing.
The Stack
Services running:
├── Reverse Proxy: Caddy (auto-HTTPS, dead simple config)
├── Monitoring: Uptime Kuma + Prometheus + Grafana
├── Analytics: Matomo (self-hosted, no Google)
├── Passwords: Vaultwarden
├── Notes: Hedgedoc
├── Files: Nextcloud
├── Media: Jellyfin
├── Git: Gitea + Drone CI
├── DNS: AdGuard Home
├── Automation: n8n
├── Backup: Restic → Hetzner Storage Box
└── DSGVO Scanner: Custom (more on that below)
Docker Compose Structure
I use a single docker-compose.yml with profiles so I can start subsets:
# docker-compose.yml (simplified)
version: "3.8"
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
profiles: ["core"]
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
restart: unless-stopped
volumes:
- uptime-kuma:/app/data
profiles: ["monitoring"]
matomo:
image: matomo:latest
container_name: matomo
restart: unless-stopped
depends_on:
- matomo-db
environment:
- MATOMO_DATABASE_HOST=matomo-db
volumes:
- matomo:/var/www/html
profiles: ["analytics"]
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
- ADMIN_TOKEN=${VAULTWARDEN_ADMIN_TOKEN}
- SMTP_HOST=${SMTP_HOST}
volumes:
- vaultwarden:/data
profiles: ["security"]
volumes:
caddy_data:
caddy_config:
uptime-kuma:
matomo:
vaultwarden:
Start everything: docker compose --profile core --profile monitoring --profile security up -d
Or just the core: docker compose --profile core up -d
Server Hardening Script
This is the script I run on every fresh Hetzner box:
#!/bin/bash
# server-harden.sh — Run as root on fresh Debian/Ubuntu
set -euo pipefail
# 1. SSH hardening
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
cat > /etc/ssh/sshd_config.d/hardening.conf <<EOF
PermitRootLogin prohibit-password
PasswordAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
systemctl restart sshd
# 2. Firewall
apt install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 22/tcp
ufw --force enable
# 3. Fail2ban
apt install -y fail2ban
systemctl enable --now fail2ban
# 4. Automatic updates
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
# 5. Docker
curl -fsSL https://get.docker.com | sh
systemctl enable docker
# 6. Monitoring agent
apt install -y prometheus-node-exporter
systemctl enable --now prometheus-node-exporter
echo "✅ Server hardened. Reboot recommended."
Caddy Reverse Proxy Config
One Caddyfile handles all services with auto-HTTPS:
{
email admin@yourdomain.de
}
grafana.yourdomain.de {
reverse_proxy grafana:3000
}
uptime.yourdomain.de {
reverse_proxy uptime-kuma:3001
}
bitwarden.yourdomain.de {
reverse_proxy vaultwarden:80
}
matomo.yourdomain.de {
reverse_proxy matomo:80
}
*.yourdomain.de {
@nc host cloud.yourdomain.de
handle @nc {
reverse_proxy nextcloud:80
}
}
Backup Strategy
#!/bin/bash
# backup.sh — Runs via cron daily at 3am
export RESTIC_REPOSITORY=/mnt/storagebox/backups
export RESTIC_PASSWORD_FILE=/root/.restic-password
# Stop services for consistent backup
docker compose -f /opt/selfhosted/docker-compose.yml stop matomo nextcloud
# Backup docker volumes + config
restic backup /var/lib/docker/volumes /opt/selfhosted
# Restart
docker compose -f /opt/selfhosted/docker-compose.yml start matomo nextcloud
# Prune old backups (keep 7 daily, 4 weekly, 3 monthly)
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 3 --prune
DSGVO Compliance Note (important for EU self-hosters)
Since I'm on a German server, I actually need to ensure my self-hosted services are DSGVO compliant too. Yes, even personal projects if they collect any user data. I built a scanner that checks for common issues:
- External resource loading (Google Fonts, CDNs outside EU)
- Cookie consent status
- SSL/TLS configuration
- Missing Impressum/Datenschutz
It's free to run at nevik.de/guard/ if anyone wants to check their setup. Honestly surprised how many self-hosted services I found with Google Fonts still loading externally — that's a €500-2000 Abmahnung risk in Germany.
Performance Results
After 2 years of tuning:
- Uptime: 99.94% (2 planned reboots)
- RAM usage: 5.8GB / 7.6GB
- Disk: 109GB / 150GB
- Monthly cost: ~€15 (Hetzner) + €3.50 (Storage Box for backups)
- Time investment: ~2 hours/month for updates
Total monthly cost: €18.50 for what would cost $200+/month in SaaS subscriptions.
What I'd Do Differently
- Start with Caddy, not Nginx — Saved me hours of SSL cert management
- Use Docker profiles from day 1 — Makes testing individual services much easier
- Set up Restic immediately — I lost Nextcloud data once before I had proper backups
- Monitor from the start — Uptime Kuma takes 2 minutes to set up, saves hours of debugging
Happy to answer questions about any part of the setup. I also put together a more complete package with all the scripts, configs, and a step-by-step guide if anyone's interested — DM me and I'll share the link.
Top comments (0)