Hardening a home lab on a network I don't control
The threat model
This server doesn't sit behind a home router. It lives in a university hall common room, plugged into a campus network shared with hundreds of people I've never met, on hardware anyone could walk past. That changes the assumptions completely: I can't trust the LAN, I can't control the perimeter, and I'm not the only person with physical proximity to the box.
So the rules I set myself were: nothing listens publicly that doesn't have to, every remote path in requires a key, and anything that does face the internet 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 didn't need. Over 15 services got turned off. Each one was a small decision: what does this do, does anything I run depend on it, what breaks if it's gone. 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.
The catch I'm most glad I caught: Ubuntu's cloud-init ships its own drop-in that quietly sets PasswordAuthentication yes. Drop-ins apply in lexical order and the first value wins, so my file is prefixed 00- to guarantee it beats cloud-init's 50-. If I'd only edited the main sshd_config I'd 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 very short allow list. But the standard trap with this setup is that Docker doesn't respect UFW: when a container publishes a port, Docker writes its own iptables rules that sit in front of the UFW chain, so a service you think is firewalled is actually 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. That closed the gap between what the firewall claimed and what was actually true, and it only got closed because I tested from another machine instead of trusting the config.
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. One side effect worth knowing: 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, which is a real trade-off: I'm accepting upstream supply-chain risk in exchange for never running stale, vulnerable images on a box I don't babysit daily. For a single-operator lab, unpatched-and-forgotten is the more likely failure, so auto-update wins. 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
- Verify from outside. The Docker/UFW gap and the cloud-init override were both invisible from the configs alone. The configuration you wrote is not the same thing as the policy that's running.
- Defaults are decisions someone else made. Cloud-init's password auth and Docker's iptables behaviour are both sensible for someone, just not for this box.
- Hardening is maintenance, not a one-off. Most of this list exists because something surprised me after the "secure" install was done.
More write-ups: VLAN discovery and responsible disclosure · SSH brute-force alerter