Chapter 08 · Build Linux

Hardening a Linux VPS

A VPS differs from your laptops in one decisive way: it has a public IP address and it never sleeps. Automated scanners will find it within minutes of creation. The goal of this chapter is to make the SSH port stop existing from the internet's point of view, without ever locking yourself out.

Before you change anything about SSH

Open a second SSH session and leave it connected for the whole chapter. Every lockout story starts with someone restarting sshd on their only connection. Also confirm your provider's web console or recovery mode works now, while you still have access to check.

The first ten minutes

A brand-new VPS, still on root with a password. Fix that immediately.

# 1. Patch everything
sudo apt update && sudo apt full-upgrade -y

# 2. A non-root user with sudo
sudo adduser yourname
sudo usermod -aG sudo yourname

# 3. Give it your key (run this FROM your laptop, not on the VPS)
ssh-copy-id -i ~/.ssh/id_ed25519.pub yourname@your.vps.ip

# 4. Prove key login works BEFORE disabling passwords
ssh yourname@your.vps.ip 'sudo -v && echo "sudo ok"'

Only once step 4 succeeds should you proceed. That ordering is the entire safety mechanism.

Lock down sshd

/etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
AllowUsers yourname
MaxAuthTries 3
LoginGraceTime 30

AllowAgentForwarding no
X11Forwarding no

ClientAliveInterval 30
ClientAliveCountMax 6

LogLevel VERBOSE
sudo sshd -t                       # validate — never skip
sudo systemctl reload ssh          # 'ssh' on Ubuntu 24.04+, 'sshd' elsewhere

Then, in your second terminal, open a fresh connection and confirm it still works before closing anything.

Firewall

Default deny inbound, allow outbound, then open only what the world genuinely needs:

sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH                 # temporarily — removed shortly
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
Docker bypasses UFW entirely

This catches people constantly. Docker writes its own iptables rules in a chain that is evaluated before UFW's, so docker run -p 5432:5432 exposes that port to the internet no matter what ufw status claims. Publish to loopback explicitly:

docker run -p 127.0.0.1:5432:5432 postgres

Then reach it over the tailnet, or through an SSH tunnel. Verify from outside the box with nmap rather than trusting the firewall's own summary.

Close the front door

This is the step that changes the security posture qualitatively. Once Tailscale is on the VPS, SSH no longer needs to be reachable from the internet at all.

Do it once somewhere harmless first

Deleting the rule that is currently keeping you logged in is the move people postpone for weeks, and reasonably so. In the sandbox it is a checkbox: turn off allow 22/tcp from anywhere, confirm the tailnet probe still gets in, then scan the public address and watch it time out. The same page reproduces the Docker bypass below in one click, which is worth more than reading about it.

Whose view
WHO IS REACHING THE VPS WHAT THEY GET AT THIS STEP the internet scanners · botnets credential stuffing arrives in minutes VPS · 203.0.113.10 :80 :443 · public the web server, on purpose AUTH FAILURES IN THE LOG a port that is not there cannot be brute-forced, fuzzed, or hit by tomorrow's pre-auth CVE your devices MacBook · iPhone inside WireGuard 100.x addresses no tailnet yet one way in — the public one 100.x :22 · tailnet Tailscale SSH :22 open · password root login allowed :22 open · keys only still a public door :22 · no public rule removed from ufw ≈ 4,800 a day, and none of it stops 0 — there is nothing left to knock on in · and it will keep guessing password authentication is on root login is permitted every attempt is a free try in · through the same door they use root, over the public internet, with a password this is the first ten minutes' whole job fix it before anything else reaches :22 · always denied PasswordAuthentication no · PermitRootLogin no every attempt now fails at the daemon the log noise does not change at all in · your key, on the public port sshd -t passed, then reload, then re-test always from a second, already-open session the port is hardened, not hidden reaches :22 · the rule allows it ufw default deny closed everything else but allow OpenSSH is still in the set a hardened door is still a door in · and ufw is holding it open allow OpenSSH is your safety line for now keep it until the tailnet path is proven 80 and 443 stay open on purpose reaches :22 · nothing was removed Tailscale added a path, it did not close one and nothing should close until step 4 the scan cannot tell anything happened two ways in · public and tailnet tailscale up --ssh, on the VPS having both at once is the point remove nothing yet reaches :22 · one command from gone the tailnet path is proven working the public rule has done its job now it is safe to delete proven · a shell over the tailnet ssh yourname@vps-hostname, new terminal this test is what makes step 5 safe skip it and step 5 is a coin flip nothing to reach · filtered nmap -Pn -p22 → 22/tcp filtered no banner, no handshake, no prompt the log noise stops. Not reduced — stopped. in · tailnet only, and that is enough mosh and tmux ride inside WireGuard keep the provider console tested it is now your recovery path
  1. 0 · Day oneA fresh VPS, root, password authentication, port 22 open to everyone. The scanners do not need to find you — they are already sweeping the whole address space, and your box joined it minutes ago. Every one of those attempts is a free guess.
  2. 0 · Day one, from your sideYou are logging in through the exact door the scanners are using, as root, with a password. Nothing about this is unusual — it is how every provider hands the machine over. It is also the single worst state the machine will ever be in.
  3. 1 · sshd hardened — and the noise is unchangedNo root, no passwords, three tries, thirty seconds. Every attempt now dies at the daemon. Notice what did not happen: the scanning did not slow down at all. Hardening changes the outcome of each attempt, not the number of them.
  4. 1 · Prove the key, then take the password awayKey login first, verified, from a second session you leave open. Then sshd -t, then reload, then connect again in a third terminal before you close anything. That ordering is the entire safety mechanism.
  5. 2 · The firewall closes everything except what you openedDefault deny inbound is a large improvement for every port you forgot about — and no improvement at all for port 22, because ufw allow OpenSSH is still in the rule set. The scanner sees exactly what it saw yesterday.
  6. 2 · Keep the SSH rule for nowIt looks redundant the moment Tailscale is running. It is not redundant, it is your safety line — the thing that gets you back in if the tailnet path turns out not to work. Ports 80 and 443 stay open because they are genuinely public.
  7. 3 · Tailscale changes nothing out hereA new path exists, and from the internet it is entirely invisible: no port, no banner, no handshake. Also — importantly — nothing was closed. Adding a path and removing a path are two separate steps, and they must stay that way.
  8. 3 · A second way insudo tailscale up --ssh. You now have two independent routes to the box. This is the state people rush through, and it is the one that makes the next two steps survivable. Remove nothing while you are here.
  9. 4 · Still open, for one more commandThe public rule has done its job: it kept you in the machine while you built the replacement. From out here nothing has changed yet — the last step is the one that matters, and it is about to happen.
  10. 4 · Prove the new path before you delete the old oneA fresh terminal, over the tailnet: ssh yourname@vps-hostname. If that does not work, stop and fix it — you still have the public port. This test is the whole difference between a change and a lockout.
  11. 5 · The port stops existingsudo ufw delete allow OpenSSH, and 22/tcp goes to filtered. The log noise stops — not reduced, stopped. Brute force, weak-key scans and every future OpenSSH pre-auth bug all require reaching the port first, and now none of them can.
  12. 5 · Tailnet only, and that is enoughSSH, Mosh and tmux all ride inside WireGuard, so Mosh's UDP range needs no public rule either. The cost is that your tailnet is now the only path — so keep the provider's console access tested, and treat it as the recovery route it is.
The log noise stops completely. Not reduced — stopped. Brute-force attempts, weak-key scans, and every future OpenSSH pre-auth vulnerability all require reaching the port first. This is worth more than any amount of password policy — step through it as An internet scanner and watch how little steps 1 to 4 change from the outside, and how much step 5 does.
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh

# Tailscale SSH IS supported as a server on Linux — use it
sudo tailscale up --ssh

# Verify from your laptop, over the tailnet, in a NEW terminal
ssh yourname@vps-hostname

# Only once that works: remove SSH from the public firewall
sudo ufw delete allow OpenSSH
sudo ufw status verbose

If you prefer OpenSSH to Tailscale SSH, bind it to the tailnet interface instead:

# Both families. One ListenAddress line stops sshd listening on
# everything else — including all of IPv6, which MagicDNS still
# hands out. Chapter 05 explains the failure this causes.
ListenAddress 100.x.y.z                                  # tailscale ip -4
ListenAddress fd7a:115c:a1e0:ab12:4843:cd96:6475:ca41    # tailscale ip -6
Do not disable key expiry casually

For an unattended server it is tempting to turn key expiry off so it never drops off the tailnet. That is defensible — but if it is now your only path in, a Tailscale outage or an expired key means a trip to the provider's web console. Keep that console access tested, or leave a firewalled emergency SSH rule limited to a static IP you control.

Keep it patched by itself

Unattended security updates are the highest-value ongoing control on a server you do not log into daily:

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

# Confirm it will actually act
sudo unattended-upgrade --dry-run --debug | tail -20
/etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";

Automatic reboots are appropriate for a dev VPS and inappropriate for anything serving traffic without redundancy — decide deliberately rather than by default.

fail2ban — still worth it, for less

With SSH off the public internet, fail2ban has almost nothing left to defend on port 22. It remains useful for services that are public — your web server, a mail service — so install it and point it at those:

sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status
Order of operations

fail2ban is a rate limiter, not an access control. It reduces noise from attackers who can already reach a service. Removing reachability, as above, is strictly stronger — do that first and treat fail2ban as defence in depth for what is left.

Kernel and network hardening

/etc/sysctl.d/99-hardening.conf
# Ignore ICMP redirects and source routing — classic MITM primitives
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0

# Log packets with impossible source addresses
net.ipv4.conf.all.log_martians = 1

# SYN flood resistance
net.ipv4.tcp_syncookies = 1

# Do not expose kernel pointers or dmesg to unprivileged users
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1

# Restrict ptrace to direct children only
kernel.yama.ptrace_scope = 1
sudo sysctl --system
Leave IP forwarding alone if Tailscale needs it

Guides often suggest net.ipv4.ip_forward = 0. If this VPS is a subnet router or exit node, that setting breaks it. Know which role the machine plays before copying hardening snippets.

Know what is listening

The same audit as the Mac, and the same conclusion — application services belong on loopback:

sudo ss -tulpn | grep LISTEN

# From ANOTHER machine, check what the world actually sees
nmap -Pn -p- your.vps.ip

Scanning from outside is the only trustworthy check. A host firewall's own status output tells you what it intends, not what is true — the Docker case above is exactly this gap.

Logs and time

# Accurate clocks — log correlation and TLS both depend on it
timedatectl status

# Watch authentication in real time
sudo journalctl -u ssh -f
sudo journalctl -u tailscaled -f

# Persist logs across reboots
sudo mkdir -p /var/log/journal && sudo systemd-tmpfiles --create --prefix /var/log/journal

Dev environment

sudo apt install -y tmux mosh git build-essential
curl -fsSL https://herdr.dev/install.sh | sh

Mosh needs UDP 60000–61000 between you and the server. Over the tailnet that is carried inside WireGuard and needs no public firewall rule — another reason not to reopen the front door.

Checklist

  • System fully patched.
  • Non-root user with sudo; key-based login proven working before passwords disabled.
  • PermitRootLogin no and PasswordAuthentication no, validated with sshd -t.
  • UFW default-deny inbound, with only genuinely public ports open.
  • Docker published ports bound to 127.0.0.1, verified from outside with nmap.
  • Tailscale installed; tailscale up --ssh working.
  • Public SSH rule removed only after tailnet access was confirmed.
  • Provider console access tested as a recovery path.
  • unattended-upgrades installed and dry-run verified.
  • sysctl hardening applied, with forwarding left correct for the machine's role.
  • External nmap scan shows only the ports you intend.
  • tmux, mosh and Herdr installed.

The commands in this guide change firewall and login settings, and can lock you out of a machine. Practise on something disposable first. Everything here is provided as is, with no warranty — you accept the risk of running it. Read the disclaimer.