DEV Community

Nevik Schmidt
Nevik Schmidt

Posted on

My complete self-hosting stack: Docker Compose + hardening scripts I use on Hetzner (sharing everything)

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)
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Start with Caddy, not Nginx — Saved me hours of SSL cert management
  2. Use Docker profiles from day 1 — Makes testing individual services much easier
  3. Set up Restic immediately — I lost Nextcloud data once before I had proper backups
  4. 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)