Hardening a home lab on a network I do not control
The threat model
The server does not sit behind a home router. It runs on a shared campus network with no perimeter under my control and no physical isolation. Three assumptions follow: the LAN is untrusted, the perimeter cannot be configured, and other people have physical proximity to the machine.
Three rules follow from that. Nothing listens publicly unless it has to. Every remote path in requires a key. Anything internet-facing goes through an outbound tunnel rather than an open inbound port.
The base system
Ubuntu Server 24.04 on a repurposed HP EliteDesk 800 G1. First pass after install: static addressing via netplan, then walking the list of running services with systemctl and disabling everything the machine did not need. Over 15 services got turned off, each checked first for dependencies. Sudo access is least-privilege through a sudoers.d drop-in rather than blanket group membership.
SSH
Key-only authentication, root login disabled, three auth attempts max, short login grace time. All of it lives in a drop-in at /etc/ssh/sshd_config.d/ rather than edits to the main config.
Ubuntu's cloud-init ships its own drop-in that sets PasswordAuthentication yes. Drop-ins apply in lexical order and the first value wins, so my file is prefixed 00- to guarantee it beats the cloud-init drop-in at 50-. If I had only edited the main sshd_config I would have believed password auth was off while it was still on.
On top of that, fail2ban watches the SSH log with a strict jail: three failures inside an hour earns a 24-hour ban, enforced through nftables.
The firewall, and the Docker gotcha
UFW runs default-deny inbound with a short allow list. Docker does not respect UFW: when a container publishes a port, Docker writes its own iptables rules ahead of the UFW chain, so a service that appears firewalled is reachable from the whole network.
The fix is the DOCKER-USER chain, which Docker guarantees to consult first. A systemd oneshot unit runs after docker.service on every boot and rebuilds it: allow established connections, allow the address ranges that should reach the containers, drop everything else. The gap was found by port-scanning the host from a separate machine, not by reading the UFW rules.
Public access without open ports
Anything reachable from the internet, including this website, goes through a Cloudflare Tunnel. The daemon makes an outbound connection and traffic comes back down it, so the server itself has no inbound ports exposed beyond SSH on the local network. Side effect: tunnelled traffic arrives at nginx as localhost, which silently breaks log-based tools like fail2ban. Restoring the real client IPs via set_real_ip_from and the CF-Connecting-IP header fixed the logs and made the bans meaningful again.
The website itself is served by nginx with a locked-down Content-Security-Policy and a full set of security headers (A+ on securityheaders.com), and the nginx vhost listens on localhost only, reachable solely through the tunnel.
Operations
Five applications run as Docker Compose services, including Forgejo for self-hosted Git and Uptime Kuma for monitoring. Watchtower auto-updates images. That accepts upstream supply-chain risk in exchange for not running stale images on a machine checked infrequently. For a single-operator lab, unpatched and forgotten is the more likely failure mode. Drive health is checked with smartctl on a schedule, and anything that needs attention lands in a Discord channel via webhook.
What this taught me
- The Docker/UFW gap and the cloud-init override were both invisible from the configuration files. Both were found by testing from a separate machine.
- Cloud-init's password auth default and Docker's iptables behaviour are both reasonable defaults in other contexts, and wrong for this one.
- Most items on this list were added after the initial install, in response to something found later.
More write-ups: VLAN discovery and responsible disclosure · SSH brute-force alerter