Security Hardening Guide¶
Security configuration to protect against script kiddies, malware, and DoS attacks.
Threat Model¶
| Threat | Likelihood | Mitigation |
|---|---|---|
| Script kiddies | High | Fail2ban, rate limiting, no exposed ports | | Malware/botnets | Medium | 2FA, updates, network segmentation | | DoS attacks | Medium | Cloudflare, rate limiting, geo-blocking | | Credential stuffing | Medium | 2FA, strong passwords, Vaultwarden | | Doxxing | Low | WHOIS privacy, Cloudflare proxy |
Attack Surface Minimization¶
Public Exposure¶
Only one service exposed to internet: Headscale on VPS.
Internet Access:
├── hs.cronova.dev (Headscale) ← Only public endpoint
│ └── Port 443 (HTTPS)
│ └── Port 3478 (STUN/DERP)
│
└── Everything else via Tailscale mesh (no public ports)
├── Home Assistant
├── Jellyfin
├── Vaultwarden
├── Frigate
└── All internal services
VPS Firewall (UFW)¶
# Reset and configure UFW
sudo ufw reset
sudo ufw default deny incoming
sudo ufw default allow outgoing
# SSH (consider changing port)
sudo ufw allow 22/tcp
# Headscale only
sudo ufw allow 443/tcp
sudo ufw allow 3478/udp
# Tailscale (auto-managed, but explicit)
sudo ufw allow in on tailscale0
# Enable
sudo ufw enable
sudo ufw status verbose
Fixed Homelab Firewall (OPNsense)¶
All traffic filtered through OPNsense VM (gateway since 2026-02-21):
- WAN: Block all inbound (no port forwards)
- LAN: Allow outbound, block inter-VLAN
- IoT VLAN (10): Configured, rules pending
- Guest VLAN (20): Configured, rules pending
- Access via Tailscale only
Authelia Forward Auth — Deployed¶
Authelia (Okẽ) provides SSO + TOTP 2FA for services behind Caddy on Docker VM:
- Protected: Yrasema (Jellyfin), Ysyry (Dozzle), Kuatia (BentoPDF), Mbyja (Homepage), Papa (Grafana), Aranduka (Paperless-ngx)
- Own auth (not protected): Jara (HA), Taguato (Frigate), Vaultwarden, Vera (Immich), Forgejo
- Notifier: Filesystem (writes codes to
/data/notification.txt), not SMTP - 2FA: TOTP via Authy app
Two-Factor Authentication (2FA)¶
Hardware Key¶
YubiKey 5C NFC available for hardware-based 2FA:
- USB-C + NFC for phone/laptop
- Supports FIDO2, WebAuthn, TOTP
- Use for most critical accounts (Vaultwarden master, Cloudflare, GitHub)
Service 2FA Matrix¶
| Service | 2FA Method | Priority | Status |
|---|---|---|---|
| Vaultwarden | TOTP or YubiKey | Critical | Available | | Authelia (Okẽ) | TOTP via Authy | Critical | Active (protects 6 services) | | Headscale | OIDC + 2FA | Critical | Pending (CLI-only for now) | | Proxmox | TOTP or YubiKey | Critical | Available | | Home Assistant | TOTP | High | Available | | OPNsense | TOTP | High | Pending | | Start9 | TOTP | High | Pending | | Jellyfin | None (behind Authelia) | Low | Protected via forward auth | | *arr stack | None (internal only) | Low | — |
Vaultwarden 2FA Setup¶
Enforce 2FA for all users¶
Add to docker-compose.yml environment:
Headscale OIDC Integration¶
For web-based admin with 2FA, integrate with an OIDC provider.
Option 1: Authelia (self-hosted)¶
# docker-compose.yml addition
authelia:
image: authelia/authelia:latest
volumes:
- ./authelia:/config
environment:
- TZ=America/Asuncion
Option 2: Use pre-auth keys only (simpler)¶
No web admin exposed. Manage via CLI:
# All admin via SSH + CLI
docker exec headscale headscale users list
docker exec headscale headscale nodes list
Proxmox 2FA Setup¶
- Datacenter → Permissions → Two Factor
- Add TOTP for root user
- Require 2FA for all admin users
Home Assistant 2FA¶
- Profile → Multi-factor Authentication
- Enable "Authenticator app"
- Scan QR code with authenticator
OPNsense 2FA¶
- System → Access → Servers → Add
- Type: Local + Timebased One-time Password
- System → Access → Users → Edit
- OTP seed: Generate new
Fail2ban Configuration¶
VPS Installation¶
SSH Protection¶
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 3
banaction = ufw
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 24h
Headscale Protection¶
# /etc/fail2ban/jail.local
[headscale]
enabled = true
port = 443
filter = headscale
logpath = /var/log/headscale/headscale.log
maxretry = 5
bantime = 1h
# /etc/fail2ban/filter.d/headscale.conf
[Definition]
failregex = ^.*Failed authentication.*from <HOST>.*$
^.*Invalid token.*from <HOST>.*$
ignoreregex =
Docker Container Protection¶
For services in Docker, use fail2ban with Docker logs:
# /etc/fail2ban/jail.local
[vaultwarden]
enabled = true
port = 443
filter = vaultwarden
logpath = /var/lib/docker/containers/*vaultwarden*/*-json.log
maxretry = 3
bantime = 24h
# /etc/fail2ban/filter.d/vaultwarden.conf
[Definition]
failregex = ^.*"Username or password is incorrect".*"Client":\s*"<HOST>".*$
ignoreregex =
Fail2ban Commands¶
# Check status
sudo fail2ban-client status
# Check specific jail
sudo fail2ban-client status sshd
# Unban IP
sudo fail2ban-client set sshd unbanip 1.2.3.4
# View banned IPs
sudo fail2ban-client get sshd banned
Rate Limiting¶
Caddy Rate Limiting¶
# VPS Caddyfile - rate limit Headscale
hs.cronova.dev {
rate_limit {
zone headscale {
key {remote_host}
events 100
window 1m
}
}
reverse_proxy localhost:8080
}
OPNsense Rate Limiting¶
- Firewall → Settings → Advanced
- Enable "Firewall Adaptive Timeouts"
- Firewall → Aliases → Add
- Name: rate_limit_block
- Type: URL Table (IPs)
DNS Privacy¶
Pi-hole Upstream (DNS-over-HTTPS)¶
Use Cloudflared for encrypted DNS:
# Add to Pi-hole docker-compose.yml
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: proxy-dns
environment:
- TUNNEL_DNS_UPSTREAM=https://1.1.1.1/dns-query,https://1.0.0.1/dns-query
- TUNNEL_DNS_PORT=5053
- TUNNEL_DNS_ADDRESS=0.0.0.0
networks:
- pihole-net
Pi-hole configuration:
- Custom upstream DNS:
cloudflared#5053
Alternative: Unbound with DoT¶
# /etc/unbound/unbound.conf.d/dns-over-tls.conf
forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
IP Privacy & Anti-Doxxing¶
Cloudflare Proxy¶
All public domains use Cloudflare proxy (orange cloud):
| Domain | Proxy | Notes |
|---|---|---|
| cronova.dev | Yes | Static site | | hs.cronova.dev | No | Headscale needs direct IP | | verava.ai | Yes | When purchased |
For Headscale: IP is exposed, but:
- Only serves Tailscale clients
- Fail2ban protects against abuse
- Can geo-block if needed
WHOIS Privacy¶
Ensure WHOIS privacy is enabled:
- Cloudflare Registrar - Privacy included free
- Other registrars - Enable WHOIS privacy/redaction
Verify:
Email Privacy¶
- Don't use personal email in public configs
- Use domain email:
<admin@cronova.dev> - Forward to personal email privately
Git Privacy¶
Check for exposed info:
# Search for emails in repo
git log --all --format='%ae' | sort -u
# Search for potential secrets
grep -r "password\|secret\|key\|token" --include="*.yml" --include="*.md"
Container Security¶
Docker Hardening¶
# docker-compose.yml security options
services:
app:
security_opt:
- no-new-privileges:true
read_only: true # Where possible
user: "1000:1000" # Non-root
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Only if needed
Image Security¶
# Use specific tags, not :latest in production
image: vaultwarden/server:1.30.1
# Scan images for vulnerabilities
docker scout cves vaultwarden/server:latest
Network Isolation¶
# Separate networks per service group
networks:
frontend:
internal: false
backend:
internal: true # No internet access
Update Strategy¶
Automatic Security Updates¶
# Install unattended-upgrades
sudo apt install unattended-upgrades
# Configure
sudo dpkg-reconfigure unattended-upgrades
# /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "false";
Container Updates¶
# Weekly container update script
#!/bin/bash
cd /opt/homelab/docker/vps
docker compose pull
docker compose up -d
# Notify
curl -d "VPS containers updated" https://notify.cronova.dev/cronova-info
Watchtower (Automated) — Deployed¶
# Deployed on Docker VM (maintenance stack)
watchtower:
image: nicholas-fedor/watchtower:1.14.2 # Maintained fork (containrrr abandoned/Docker 29+ incompatible)
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_SCHEDULE=0 0 4 * * 0 # Sundays 4 AM
- WATCHTOWER_LABEL_ENABLE=true # Opt-in via container labels
Update strategy (opt-in via com.centurylinklabs.watchtower.enable=true label):
| Category | Services | Strategy |
|---|---|---|
| Pinned (manual bump) | victoriametrics, vmagent, vmalert, alertmanager, grafana, authelia, paperless-ngx | Version pinned in compose — Watchtower label present but no-op | | Excluded (no label) | vaultwarden, frigate, homeassistant, immich-db, immich-valkey, paperless-db, paperless-redis | No Watchtower label — manual only | | Excluded (label=false) | caddy (Docker VM) | Explicitly disabled — custom build with Cloudflare plugin | | Auto-updated | dozzle, bentopdf, homepage, cadvisor, glances, sonarr, radarr, prowlarr, qbittorrent, jellyfin, mosquitto, pihole, immich-server, immich-ml + backup sidecars | Low-risk or stateless — Watchtower updates on schedule |
Monitoring & Alerts¶
Security Alerts in Uptime Kuma¶
Add monitors for security events:
| Monitor | Type | Alert |
|---|---|---|
| VPS SSH | TCP 22 | If down, possible attack | | Fail2ban status | Push | On ban events | | UFW logs | Push | On blocked connections |
Log Monitoring¶
# Watch auth failures
sudo tail -f /var/log/auth.log | grep -i "failed\|invalid"
# Watch UFW blocks
sudo tail -f /var/log/ufw.log
Fail2ban Notifications¶
# /etc/fail2ban/action.d/ntfy.conf
[Definition]
actionban = curl -d "Banned <ip> from <name> jail" https://notify.cronova.dev/cronova-warning
actionunban = curl -d "Unbanned <ip> from <name> jail" https://notify.cronova.dev/cronova-info
Backup Security¶
Encrypted Backups¶
Restic encrypts by default:
Backup Key Storage¶
- Store restic password in Vaultwarden
- Paper backup in secure location
- Never commit password to git
Offsite Encryption¶
rclone crypt adds additional layer:
# Google Drive data is encrypted client-side
rclone config
# Type: crypt
# Remote: gdrive:homelab-backup
# Password: (different from restic password)
Incident Response¶
If VPS Compromised¶
- Isolate: Remove from Tailscale
- Revoke: Invalidate all Headscale auth keys
- Rotate: Change all passwords/keys
- Rebuild: Fresh VPS from backup
- Audit: Check access logs
If Credentials Leaked¶
- Change Vaultwarden master password
- Rotate all stored passwords
- Revoke all Tailscale pre-auth keys
- Check for unauthorized devices in mesh
If DoS Attack¶
- Cloudflare: Enable "Under Attack" mode
- Geo-block: Block attacking countries
- Rate limit: Increase restrictions
- Report: To VPS provider
Security Checklist¶
Initial Setup¶
- [ ] UFW configured and enabled
- [ ] SSH key-only authentication
- [ ] SSH root login disabled
- [ ] Fail2ban installed and configured
- [ ] 2FA enabled on Vaultwarden
- [ ] 2FA enabled on Proxmox
- [ ] WHOIS privacy verified
- [ ] Cloudflare proxy enabled (where applicable)
Monthly Review¶
- [ ] Check fail2ban ban logs
- [ ] Review Uptime Kuma alerts
- [ ] Verify backups are encrypted
- [ ] Check for system updates
- [ ] Review Tailscale device list
- [ ] Rotate any compromised credentials
After Incident¶
- [ ] Document what happened
- [ ] Identify root cause
- [ ] Implement fixes
- [ ] Update this document
Quick Reference¶
Emergency Commands¶
# Block IP immediately
sudo ufw deny from 1.2.3.4
# Check active connections
sudo netstat -tulpn
# Kill suspicious process
sudo kill -9 <pid>
# Check for rootkits
sudo rkhunter --check
# View recent logins
last -n 20
# Check for unauthorized SSH keys
cat ~/.ssh/authorized_keys
Security Tools¶
| Tool | Purpose | Install |
|---|---|---|
| fail2ban | Ban brute forcers | apt install fail2ban |
| ufw | Firewall | apt install ufw |
| rkhunter | Rootkit detection | apt install rkhunter |
| lynis | Security audit | apt install lynis |
| clamav | Antivirus | apt install clamav |