Infrastructure Docs

Generic self-hosting reference — adapt these templates to your own hardware and domain.

Caddyfile Reference

# /etc/caddy/Caddyfile — static site + CMS tenants
example.com {
    root * /var/www/example
    file_server
    encode zstd gzip

    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy same-origin
        Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'"
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
    }

    @downloads path /downloads/files/*
    header @downloads Access-Control-Allow-Origin "*"
}

# CMS tenant reverse proxies (repeat per tenant)
tenant1.example.com {
    reverse_proxy localhost:2368
}
tenant2.example.com {
    reverse_proxy localhost:2369
}
tenant3.example.com {
    reverse_proxy localhost:2370
}
tenant4.example.com {
    reverse_proxy localhost:2371
}

Docker Compose Pattern (CMS tenant)

services:
  ghost-tenant:
    image: ghost:6
    restart: always            # survives reboot with zero intervention
    ports:
      - "127.0.0.1:2368:2368"  # only reachable through Caddy
    environment:
      url: https://tenant1.example.com
      database__client: sqlite3
      database__connection__filename: /var/lib/ghost/content/data/ghost.db
    volumes:
      - ./content/tenant1:/var/lib/ghost/content

Security Hardening Checklist

  • SSH: key-only auth, PasswordAuthentication no, non-default port optional
  • UFW default-deny inbound; allow only 22 (or SSH port), 80, 443
  • fail2ban on sshd + Caddy access logs
  • Unattended upgrades: unattended-upgrades package enabled
  • All services bind to 127.0.0.1 — only Caddy faces the network
  • No database on the static site; content is flat JSON files
  • Backups: nightly tarball of /var/www/example + CMS content dirs to offline media
  • Version control: local Git repo with nightly auto-commit via cron
  • Hidden files blocked: deny access to .git*, .env*, .ht*, *.bak, *.tar.gz
  • PWA manifests: use .json extension (not .webmanifest) for correct MIME type

UFW + fail2ban Templates

# UFW
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp comment 'SSH'
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
sudo ufw enable

# /etc/fail2ban/jail.local
[sshd]
enabled = true
maxretry = 4
bantime = 1h

[caddy-status]
enabled  = true
port     = http,https
logpath  = /var/log/caddy/access.log
maxretry = 20
findtime = 1m
bantime  = 10m

Port Forwarding Diagram

Internet
   │  80/443 (router port-forward)
   ▼
┌───────────────────────────── Server (Linux) ────┐
│  Caddy :80/:443  ← the ONLY exposed service     │
│    ├── example.com          → /var/www/example  │
│    ├── tenant1.example.com  → 127.0.0.1:2368    │
│    ├── tenant2.example.com  → 127.0.0.1:2369    │
│    ├── tenant3.example.com  → 127.0.0.1:2370    │
│    └── tenant4.example.com  → 127.0.0.1:2371    │
│  UFW: deny all except 22, 80, 443               │
└─────────────────────────────────────────────────┘

Backup & Version Control

# Nightly auto-commit script: /usr/local/bin/auto-commit-backup.sh
#!/bin/bash
cd /var/www/example
if [ -n "$(git status --porcelain)" ]; then
  sudo git add .
  sudo git commit -m "Auto-commit backup - $(date '+%Y-%m-%d %H:%M')"
  echo "[$(date '+%Y-%m-%d %H:%M')] Committed changes" >> /var/log/git-auto-backup.log
else
  echo "[$(date '+%Y-%m-%d %H:%M')] No changes to commit" >> /var/log/git-auto-backup.log
fi

# /etc/crontab — runs nightly at 2 AM
0 2 * * * root /usr/local/bin/auto-commit-backup.sh

# .gitignore — keep binaries and temp files out of Git
*.bak
*.zip
*.tar.gz
.DS_Store
Thumbs.db
*.log
*.tmp
*.swp
.cache/
node_modules/
dist/
build/

# Pre-deployment tarball backup (ALWAYS do this first)
cd /var/www/example
sudo tar -czf ~/site-backup-$(date +%Y%m%d-%H%M%S).tar.gz .