M-Bus Gateway
← Tilbage til blog
· Hetzner· VPS· Docker Compose· Nginx· SaaS· server· deployment· sikkerhed· backup

Hetzner VPS med Docker Compose til SaaS-platform — opsætning og sikkerhed

Opsætning af Hetzner VPS til IoT SaaS-platform: Docker Compose stack, Nginx reverse proxy, Let's Encrypt TLS, firewall-regler, backup-strategi og monitoring.

Af M-Bus Gateway

M-Bus Gateway SaaS-platformen kører på en Hetzner VPS i Falkenstein (EU) med Docker Compose. Her er den komplette opsætning fra tom server til produktionsklar platform.


Hardware-valg: Hetzner CX32

Hetzner Cloud CX32 (valgt):
  vCPU: 4 (AMD EPYC)
  RAM: 8 GB
  SSD: 80 GB NVMe
  Netværk: 20 TB/måned (mere end rigeligt)
  Pris: ~15 EUR/måned (EU-hostet = GDPR-kompatibelt)
  Lokation: Falkenstein (DE) — Hetzner Object Storage samme datacenter

Load estimat:
  FastAPI: 2-4 workers, <500 MB RAM
  TimescaleDB: 2-4 GB RAM (med cache)
  Celery workers: 200-400 MB
  Mosquitto: <50 MB
  Redis: 100-300 MB
  Nginx: <50 MB
  Total: ~5-6 GB RAM → CX32 passer

Docker Compose stack

# docker-compose.yml
version: "3.9"

services:
  db:
    image: timescale/timescaledb:latest-pg16
    environment:
      POSTGRES_DB: mbus
      POSTGRES_USER: mbus
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
    restart: unless-stopped
    networks:
      - internal

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    restart: unless-stopped
    networks:
      - internal

  mosquitto:
    image: eclipse-mosquitto:2.0
    ports:
      - "8883:8883"
    volumes:
      - ./mosquitto/:/mosquitto/config/:ro
      - ./certs/:/mosquitto/certs/:ro
      - mosquitto_data:/var/lib/mosquitto
    restart: unless-stopped
    networks:
      - internal
      - external

  api:
    build:
      context: ./server
      dockerfile: Dockerfile
    environment:
      DATABASE_URL: postgresql+asyncpg://mbus:${POSTGRES_PASSWORD}@db/mbus
      REDIS_URL: redis://redis:6379/0
      MQTT_HOST: mosquitto
      SECRET_KEY: ${SECRET_KEY}
      BREVO_API_KEY: ${BREVO_API_KEY}
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
    depends_on:
      - db
      - redis
      - mosquitto
    volumes:
      - ./ui/dist:/app/static:ro
      - ./server/templates:/app/templates:ro
    restart: unless-stopped
    networks:
      - internal

  worker:
    build: ./server
    command: celery -A server.src.workers.celery_app worker
             --queues default,high --concurrency 2 --loglevel=info
    environment:
      DATABASE_URL: postgresql+asyncpg://mbus:${POSTGRES_PASSWORD}@db/mbus
      REDIS_URL: redis://redis:6379/0
    depends_on:
      - db
      - redis
    restart: unless-stopped
    networks:
      - internal

  beat:
    build: ./server
    command: celery -A server.src.workers.celery_app beat --loglevel=info
    environment:
      REDIS_URL: redis://redis:6379/0
      DATABASE_URL: postgresql+asyncpg://mbus:${POSTGRES_PASSWORD}@db/mbus
    depends_on:
      - redis
    restart: unless-stopped
    networks:
      - internal

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
      - ./ui/dist:/var/www/html:ro
    depends_on:
      - api
    restart: unless-stopped
    networks:
      - internal
      - external

networks:
  internal:
    internal: true   # Ikke tilgængeligt udefra
  external:

volumes:
  postgres_data:
  redis_data:
  mosquitto_data:

Nginx reverse proxy

# nginx/nginx.conf
server {
    listen 80;
    server_name mbus-gateway.dk www.mbus-gateway.dk;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name mbus-gateway.dk www.mbus-gateway.dk;

    ssl_certificate /etc/letsencrypt/live/mbus-gateway.dk/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mbus-gateway.dk/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512;

    # Statisk frontend (React build):
    location / {
        root /var/www/html;
        try_files $uri $uri/ /index.html;
    }

    # API proxy:
    location /api/ {
        proxy_pass http://api:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Webhooks:
    location /webhooks/ {
        proxy_pass http://api:8000;
        proxy_read_timeout 60s;
    }

    # Security headers:
    add_header X-Frame-Options SAMEORIGIN;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'";
}

Firewall: Hetzner Cloud Firewall

# Hetzner Cloud Firewall rules:
# Kun åbn det absolutt nødvendige:

Indgående:
  TCP 22    → SSH (begræns til din IP!) 
  TCP 80    → HTTP (redirect til HTTPS)
  TCP 443   → HTTPS
  TCP 8883  → MQTT TLS (fra gateways)

Alt andet: BLOCK (implicit)

# Lokalt UFW som ekstra lag:
ufw default deny incoming
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 8883/tcp
ufw enable

Let's Encrypt TLS

# Certbot installation:
apt install certbot python3-certbot-nginx

# Hent certifikat:
certbot --nginx -d mbus-gateway.dk -d www.mbus-gateway.dk \
  --email admin@mbus-gateway.dk --agree-tos --non-interactive

# Auto-fornyelse (via certbot-timer):
systemctl enable certbot.timer

# Verificér fornyelse:
certbot renew --dry-run

Backup-strategi

#!/bin/bash
# scripts/backup.sh — køres dagligt via cron

BACKUP_DIR="/tmp/mbus-backup-$(date +%Y%m%d)"
mkdir -p "$BACKUP_DIR"

# PostgreSQL dump:
docker compose exec -T db pg_dump -U mbus mbus | \
  gzip > "$BACKUP_DIR/postgres.sql.gz"

# Upload til Hetzner Object Storage (S3-kompatibel):
s3cmd put "$BACKUP_DIR/postgres.sql.gz" \
  s3://mbus-backups/daily/postgres-$(date +%Y%m%d).sql.gz

# Ryd lokalt:
rm -rf "$BACKUP_DIR"

# Retention: Hetzner Object Storage lifecycle rule:
# → Daily backups: 30 dage
# → Weekly backups: 1 år
# → Monthly backups: 5 år (bogføringsloven)

Monitoring

# Simpel health-check script (cron hvert 5. min):
#!/bin/bash
API_URL="https://mbus-gateway.dk/api/v1/health"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL")

if [ "$STATUS" != "200" ]; then
  curl -s -X POST "https://api.brevo.com/v3/smtp/email" \
    -H "api-key: $BREVO_API_KEY" \
    -d "{\"to\":[{\"email\":\"admin@mbus-gateway.dk\"}],
          \"subject\":\"Platform nede! HTTP $STATUS\",
          \"textContent\":\"API svarede $STATUS\"}"
fi

Konklusion

Hetzner CX32 + Docker Compose er den kostnadseffektive løsning for en IoT SaaS-platform med GDPR-compliance: EU-hostet, predictable pris, kraftfuld nok til hundredevis af tenants. Nøglen er korrekt netværkssegmentering (internal Docker network), Nginx som eneste indgangspunkt og automatisk backup til Hetzner Object Storage.

Se FastAPI multi-tenant arkitektur eller MQTT TLS opsætning.