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

Published: (June 11, 2026 at 06:41 AM EDT)
4 min read
Source: Dev.to

Source: Dev.to

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. 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)

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 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.”

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 } }

#!/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

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. 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. 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.

0 views
Back to Blog

Related posts

Read more »

The spec is in the wrong place

My day job is at a large tech company. Hundreds of engineering teams, and every one of them is somewhere different on AI adoption. Some are still treating codin...

The Heuristics Say Don't

A culture that only records its disasters ends up with a biased archive. Wars documented, plagues chronicled, collapses catalogued. The quiet decades go unwritt...