- deploy-hetzner-cloud.md — CX22/CAX11 (€3.79/mo verified), hcloud TF - deploy-vps-generic.md — provider-agnostic cloud-init + SSH first-contact - security-ssh-hardening.md — sshd_config.d/99-kei.conf baseline matrix - security-firewall-ufw.md — ufw default-deny + rate limiting + intent YAML - security-tls-caddy.md — Caddy 2 auto-ACME, HTTP-01 / DNS-01, systemd - security-audit-logging.md — auditd rules + journald persistence - security-patching.md — unattended-upgrades + needrestart + reboot window All blocks reference RULE 0.8 env-var-only secrets and cite provider specifics per RULE 0.4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3.4 KiB
SECURITY — Firewall (ufw default-deny + rate limiting + nftables alt)
Posture — default-deny-in / allow-out:
ufw default deny incoming
ufw default allow outgoing
ufw default deny routed # do NOT forward unless explicitly routing
ufw limit 22/tcp comment 'ssh (rate-limited: 6 conn / 30s)'
ufw logging medium
ufw --force enable
ufw limit = per-source-IP brute-force mitigation at the kernel level (iptables recent module). Use for SSH — never use it for app traffic (false positives on shared-NAT clients).
Layer ordering (read top-down):
- Cloud Firewall (Hetzner Cloud Firewall / AWS Security Group / DO Firewall) — drops at the provider edge, BEFORE packets hit the VM. Cheapest layer.
- ufw on the VM — defence in depth; also covers provider-firewall misconfigs and private-network paths.
- App-level auth — sshd keys, TLS client certs, app tokens.
Both the Cloud Firewall AND ufw must agree on the port allow-list. A mismatch means "it works from provider console but not from Tailscale" or vice-versa. Use _primitives/_rust/firewall-diff/ to compare intended rules (YAML) against running ufw status.
Intended-rules YAML schema (firewall-intent.yaml):
default:
incoming: deny
outgoing: allow
routed: deny
rules:
- port: 22
proto: tcp
action: limit
from: any
comment: "ssh (rate-limited)"
- port: 443
proto: tcp
action: allow
from: any
comment: "https / caddy"
- port: 80
proto: tcp
action: allow
from: any
comment: "http / acme-http-01"
firewall-diff round-trips this against live ufw status numbered JSON-parse and prints additions/deletions. Exit 0 iff live ≡ intent.
Rate limiting patterns:
limit— built-in; 6 connections / 30 s per IP. Good for SSH.- Per-app — do it inside the app or a reverse proxy (nginx
limit_req, Caddyrate_limit), not in ufw. Kernel rate-limit doesn't understand HTTP methods. - ICMP —
ufw default allow outgoingcovers outbound; inbound ICMP should beallow(echo) for monitoring, NOT blanket-blocked (blocks path-MTU discovery).
IPv6: /etc/default/ufw → IPV6=yes (default Debian 12). Verify via ufw status verbose shows the (v6) rules. Missing IPv6 rules = a trivial bypass on dual-stack VMs.
Logging: ufw logging medium writes to /var/log/ufw.log. Forward to journald (default on systemd) or an off-box log collector. Logging high is too chatty for steady state; use it only during incident response.
nftables alternative (for hosts that have Docker-installed iptables-nft): ufw is a thin wrapper over iptables/nftables; on Docker-heavy hosts, Docker's daemon aggressively rewrites iptables and can bypass ufw. Two options:
- DOCKER_OPTS=
--iptables=false(and do NAT yourself — advanced). ufw-dockercompanion (https://github.com/chaifeng/ufw-docker, not bundled in Debian — pin a tagged release, review the script BEFORE install).
On non-Docker hosts, ufw is sufficient. On Docker hosts, EITHER isolate (dedicated host + Cloud Firewall only) OR use ufw-docker — don't half-configure.
Forbidden: ufw default allow incoming "temporarily"; allow from any to any port 22 without limit; skipping the IPv6 rule set; letting Docker silently override ufw without disabling its iptables chain; relying on ufw as the ONLY layer when a Cloud Firewall is available.