Security: What I Actually Do
No theoretical checklist—this is what’s in place and why.
SSH: keys only, no passwords
On every box that has SSH (Host 1 Windows, TrueNAS, and all five active Ubuntu VMs — Host 2 is permanently offline since 2026-04-11):
- Password auth disabled. Only public-key auth. Same key pair for the Ubuntu VMs and the Windows/TrueNAS hosts; I use one SSH config and one key path.
- Windows (Host 1): OpenSSH Server with a custom firewall rule: TCP 22 allowed only from the LAN subnet. The default “OpenSSH” rule is disabled so the scope is explicit. Authorized keys live in the usual Windows path for admin accounts. (Host 2 is permanently offline — decommissioned 2026-04-11.)
- TrueNAS: SSH enabled, password auth off, key-based only. An init script reapplies iptables at boot so SSH is only accepted from the LAN subnet.
- Ubuntu VMs: Same key; fail2ban on top (see below). No SSH from the internet—only from the LAN—because the router doesn’t port-forward 22.
So even if something on the LAN is compromised, an attacker still needs the key. And nothing is listening for SSH from the internet.
Router (MikroTik hEX)
- No management from WAN. SSH and Winbox/WebFig are only reachable from the LAN. No port forwards for the router itself.
- Firewall: Input chain allows established/related, drops invalid, allows SSH from LAN (non-standard port), allows ICMP, then drops the rest. Forward chain: fasttrack for established, drop invalid, allow hairpin NAT, then drop new inbound that isn’t DSTNAT (so only my port-forwarded 80/443 get through).
- Brute-force protection: Multi-stage address lists. After a few failed SSH attempts from an IP, that IP is added to a temporary blacklist (e.g. 24 hours). So even from the LAN, repeated guessing gets blocked.
- Source NAT: Masquerade for outbound is restricted to the LAN subnet. That way when traffic is port-forwarded in, the source IP isn’t rewritten from the router’s perspective and nginx sees the real client IP—which I use for Pi-hole admin (allow only LAN subnet in nginx).
TLS and nginx
- Protocols: TLS 1.2 and 1.3 only; no TLS 1.0/1.1. Set in the main
nginx.conf. - Ciphers: Modern suite (ECDHE, AES-GCM, CHACHA20-POLY1305). No legacy ciphers.
- Headers: HSTS, X-Content-Type-Options, X-Frame-Options, Content-Security-Policy where it makes sense. Same pattern for all public server blocks.
- Pi-hole (and any admin) blocks: In the relevant server blocks I use
allow <LAN subnet>; deny all;so those hostnames return 403 unless the request is from my LAN. server_tokens off: nginx doesn’t reveal its version number in error pages or theServerresponse header.- Rate limiting on Bitwarden auth: 20 req/min per IP (burst 5) on
/identity/connect/token. Normal browser-extension refresh never approaches this; only brute-force credential stuffing does. - Bad-path blocking (444): A shared snippet included in every public server block returns
444(silent connection close) for.env, WordPress paths, git metadata, PHP probes, and CGI traversal patterns. Scanners get no response and no version information. - WPAD suppression: Windows/macOS clients probe
/wpad.daton every HTTP request (proxy auto-detection). nginx serves a direct “no proxy” PAC response at the HTTP level so requests never redirect to HTTPS and never show as 404s in the access log.
Fail2ban on Ubuntu VMs
On all five active Ubuntu VMs (Docker host, nginx primary, Pi-hole 1, Minecraft server, Stoat Chat — secondary nginx and Pi-hole 3 are offline since Host 2 was decommissioned 2026-04-11):
- SSH jail (
sshd): After N failed attempts in a time window, the source IP is banned for 10 minutes. - nginx 4xx jail (
nginx-4xx): Bans IPs generating 20+ HTTP 4xx errors in 10 minutes for 1 hour. Catches external scanners probing for WordPress,.env, and similar paths. LAN IPs are always exempt. - Ignore: LAN subnet and localhost are in
ignoreipso I never ban my own machines. - Config: One
jail.localand afilter.d/nginx-4xx.conffilter are in the repo; generated and deployed to all active VMs so they share the same policy.
Keepalived
VRRP is authenticated with a shared secret (PASS type) so a random device on the LAN can’t claim the VIP. The secret is in my local values file, not in the repo. Both nodes use the same auth pass and same VRRP ID. (Secondary nginx is offline — Host 2 decommissioned 2026-04-11; keepalived runs in degraded single-node mode.)
Secrets and repo
- No real values in git. All configs and docs in the repo use placeholders (
detellem.com,192.168.88.0/24, etc.). Real values live invalues.yaml.local, which is gitignored. A validation script (make test) checks that no value from that file appears in any committed file—so I can’t accidentally commit an IP or key. - Passwords and keys: Stored in Bitwarden or in the local values file. Nothing sensitive in the repo.
What I don’t do (yet)
- VLANs — One flat LAN. I’ve considered segmenting (e.g. IoT, guest) but haven’t added a managed switch. For now I rely on host firewalls and nginx allow/deny.
- Restrict management by IP — SSH and Pi-hole admin are “LAN only” but any device on the LAN can try. Tighter would be to allow only specific source IPs (e.g. my daily driver and one or two static reservations). I may do that later.
- VPN for remote access — When I’m away I don’t VPN in; I don’t expose SSH or admin UIs to the internet. So “security” is “no attack surface from the internet except 80/443 to the VIP.”