
A bare-metal Debian 12 server I built and administer solo — the production machine behind the BRASILCON OJS journal. Intel i5-14400, 32 GiB RAM, 1 TB NVMe, on a residential connection in Curitiba. It is my live laboratory for the discipline that actually matters in security: deciding what gets exposed, why, and proving it. EXPOSURE AS A DELIBERATE DECISION — The threat model is simple: assume the WAN is hostile and minimize what it can see. The flagship service (the journal) reaches the internet through an outbound-only Cloudflare Tunnel — zero inbound ports for the box's most valuable workload. The only two ports forwarded on the router exist for the self-hosted TeamSpeak 6 voice server (Docker), and each is documented with its justification. Everything else — admin panels, Cockpit, file sharing (NFS/Samba), the web file manager — is LAN-only by policy, with an explicit written rule of what must never be forwarded. SSH is key-only; password authentication is disabled in sshd config. LEAST PRIVILEGE IN THE SMALL THINGS — The DuckDNS dynamic-DNS updater runs as a dedicated unprivileged system user via a systemd timer, with its token in a root-owned config file at mode 640 — not as a root cron job with the secret inline, which is how most self-hosters do it. TeamSpeak runs containerized with only its two required ports published. The Docker stacks publish no other host ports. INCIDENT FORENSICS — THE STORY I TELL IN INTERVIEWS — For ten weeks the server "randomly lost internet" every 1–2 weeks, and reboots only reset the clock. Instead of another reboot, I dug through journald and the packet path and found three stacked root causes: two DHCP clients racing on one NIC with independent renew schedules; a rogue DHCP server on the ISP modem that hijacked the default route (the smoking gun was a single journal line at 3:24 AM); and three firewall managers — firewalld, ufw, iptables-persistent — silently fighting each other and Docker, so every previous "fix" had edited a layer that wasn't doing the blocking. The cure was architectural: exactly one owner per layer. One DHCP client, one firewall authority, Docker managing its own chains. Zero recurrences since. OPERATIONS AS CODE-ADJACENT DISCIPLINE — The server is governed by a living handbook: services-and-ports map, firewall policy with rationale, full incident history with root-cause analysis, an honest risk register, and triage runbooks ordered by likelihood — written so that any competent human or AI agent could operate the machine cold. Documentation is a security control; this box treats it like one.
#!/usr/sbin/nft -f
# Default-drop. The journal has NO inbound rule (it rides the Cloudflare Tunnel).
# Exactly one firewall authority owns this box — no ufw, no firewalld fighting Docker.
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state invalid drop
ct state established,related accept
iif "lo" accept
ip protocol icmp accept
# Management surface is LAN-only by policy (SSH, Samba, Cockpit).
ip saddr 192.168.0.0/16 tcp dport { 22, 445, 9090 } accept
# SSH: key-only (PasswordAuthentication no in sshd_config).
tcp dport 22 accept
# The ONLY two WAN-forwarded services: TeamSpeak 6 voice — documented + justified.
udp dport 9987 accept # TS6 voice
tcp dport 30033 accept # TS6 file transfer
counter comment "dropped-inbound" drop
}
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }
}Default-drop ruleset. The journal has no inbound rule — it rides the tunnel.