stayz3ro.dev

Hardening a public VPS before it hosts anything

The home lab runs on a private network. A VPS does not. It’s on the public internet from the first boot, getting SSH-scanned within minutes. So before this one ran a single service, the whole job was making it safe to leave running.

This is Phase 1 and 2 of the VPS lab: the baseline hardening, then pointing stayz3ro.dev at it and pulling SSH back off the public internet.

The baseline

The provider gives you a root login. The first session’s work is to not need it again:

  • A non-root user with sudo, for everything day-to-day.
  • Key-only SSH: PermitRootLogin no, PasswordAuthentication no.
  • UFW, default-deny inbound, allowing only what’s actually needed.
  • Fail2Ban on the SSH jail.
  • Unattended-upgrades for security patches.
  • Swap, timezone, Docker, Tailscale, and a predictable folder layout under /opt.

SSH changes need a safe workflow

Editing sshd_config over SSH is how people lock themselves out. The workflow that doesn’t:

sudo sshd -t          # validate syntax, no output means OK
sudo systemctl reload ssh
# then, from a SECOND terminal, confirm you can still get in
# only now close the original session

sudo sshd -t && echo "SSH config validation passed" on netcup-prod-01
returning "SSH config validation passed"

Use reload, not restart. Reload re-reads the config without dropping existing connections, so the session you’re in survives a mistake long enough to fix it. Disable root and password login after you’ve confirmed key auth works, never before.

Fail2Ban earns its keep immediately

On a public IP the SSH scanning is constant. Fail2Ban watches the auth log and temp-bans repeat offenders. It doesn’t replace key-only auth. It’s a layer on top that cuts the noise.

fail2ban-client status sshd: the sshd jail active, with banned IPs in
the ban list

Two addresses already banned, on a server that had existed for hours.

Then: a domain, and SSH goes private

Phase 2 pointed stayz3ro.dev at the VPS. Clear the registrar’s parking records first, add the A record, add the subdomains I knew were coming (status, apps, api), and check resolution from more than one resolver:

dig +short stayz3ro.dev
dig @1.1.1.1 +short status.stayz3ro.dev
dig @8.8.8.8 +short status.stayz3ro.dev

dig +short for stayz3ro.dev and its subdomains, all resolving to the
VPS

A record isn’t done until it resolves the same way from a resolver you don’t control.

With the domain live, there was no longer any reason for port 22 to face the internet. SSH moved to Tailscale-only:

sudo ufw delete allow 22/tcp
sudo ufw allow in on tailscale0 to any port 22 proto tcp

ufw status numbered: 80 and 443 open to anywhere, 22 allowed only on
the tailscale0 interface

The management path is now:

admin workstation -> Tailscale -> netcup-prod-01 -> SSH

Public SSH from anywhere else just times out:

ssh to the VPS's public IP on port 22: connection timed
out

That’s the enterprise pattern in miniature. Administration goes through a private access layer, and only the traffic that’s meant to be public (80 and 443, for the reverse proxy that comes next) stays exposed.

Closing note

A few things worth repeating. Harden before you host: baseline security, then a domain, then services, in that order. Use reload and a second terminal for every SSH config change. Only disable risky access after proving safe access works. And treat public DNS and public SSH as separate decisions, because pointing a domain at a box is not a reason to leave its management ports open. Your own resolver can also lie to you, which is the subject of the next post.