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.
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
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
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.
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.
- 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.
- 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.
- 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.
- 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. - 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 OpenSSHis still in the rule set. The scanner sees exactly what it saw yesterday. - 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.
- 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.
- 3 · A second way in
sudo 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. - 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.
- 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. - 5 · The port stops existing
sudo ufw delete allow OpenSSH, and22/tcpgoes 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. - 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.
# 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
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
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
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
# 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
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 noandPasswordAuthentication no, validated withsshd -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 --sshworking. - 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.