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-upgradespackage 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
.jsonextension (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 .