Hardening a Linux VPS means shrinking its attack surface: create a non-root sudo user, enforce SSH key authentication, disable root and password logins, enable a default-deny firewall, add fail2ban or CrowdSec, turn on automatic security updates, and audit the result with Lynis. This checklist walks through every step on Ubuntu 24.04 and Debian 12/13.
Every time we spin up a new VPS for a client — usually a Hetzner Cloud box — we run the same hardening pass before anything else touches it. Below is that exact pass, in the order we actually do it, with the commands, the reasoning, and a quick way to verify each step worked. It’s written for a fresh Ubuntu 24.04 LTS or Debian 12/13 server, but almost everything applies to any modern systemd distro.
Read this first. Several steps below can lock you out if done carelessly. The golden rule: keep a second SSH session open while you work, and never disable a login method until you’ve confirmed the replacement works in that second session. If something breaks, you still have a live root shell to fix it — or your provider’s web console as a backstop.
The 10 steps at a glance
- Update the system and enable automatic security updates
- Create a non-root user with sudo
- Set up SSH key authentication (Ed25519)
- Harden
sshd: disable root login and password auth - Enable a default-deny firewall (UFW)
- Block brute-force attacks with fail2ban (or CrowdSec)
- Reduce the attack surface — disable services, close ports
- Apply kernel and network hardening (sysctl)
- Put admin access behind a VPN (optional but worth it)
- Log, audit, and verify with
auditdand Lynis
What VPS hardening actually protects you from
A brand-new server doesn’t get a grace period. The moment it has a public IP, automated bots start scanning and probing it. In a classic Sophos study, a cloud honeypot was attacked within 52 seconds of going live, and the honeypot network averaged around 13 attempted attacks per minute each (Sophos, Exposed: Cyberattacks on Cloud Honeypots, 2019).
The overwhelming majority of that traffic is dumb, automated SSH brute-forcing. The SANS Internet Storm Center publishes live honeypot data, and the pattern barely changes year to year: the most-tried username is root by a huge margin, followed by admin, ubuntu, user, and test; the most-tried passwords are 123456, password, admin, and root. F5 Labs found that root is attacked roughly three times more often than any other username, and that SSH brute-forcing dwarfs most other attack types by volume.
The scale keeps climbing, too. GreyNoise’s 2026 State of the Edge report tracked nearly three billion malicious sessions from 3.8 million unique IPs, and noted that 52% of remote-code-execution attempts came from IPs with no prior history — so reputation blocklists alone won’t save you. Verizon’s 2025 Data Breach Investigations Report found that credential abuse is the single most common way breaches start, and that attacks on internet-facing edge infrastructure grew roughly eightfold.
So on day one you’re not defending against a clever human who picked your box specifically. You’re defending against constant, dumb, automated noise. Almost all of it disappears the moment you enforce key-only SSH and a default-deny firewall — which is why those two steps do more for you than anything clever further down the list.
Before you start
You’ll need:
- A fresh VPS with a public IP and root (or sudo) access.
- An SSH client on your own machine. On macOS/Linux it’s built in; on Windows use the built-in OpenSSH client, WSL, or a terminal like Windows Terminal.
- 30–45 minutes. Don’t rush the SSH steps.
If your provider offers a network-level firewall (Hetzner Cloud Firewall, AWS security groups, etc.), configure it first, before the VM is even reachable. It’s free defense-in-depth that sits in front of the host: allow inbound only on SSH and 80/443, drop everything else. Even if you later misconfigure the host firewall, the edge filter is still standing. On Hetzner you create it in the Cloud Console or with the hcloud CLI — see the Hetzner Cloud Firewall docs.
Step 1 — Update the system and enable automatic security updates
First, patch everything. Old, repeated CVEs are still actively exploited — GreyNoise noted attackers hammering vulnerabilities that are years, even decades, old — so an unpatched box is low-hanging fruit.
sudo apt update && sudo apt full-upgrade -y
sudo apt autoremove --purge -y
sudo reboot # only if the kernel or libc was updated
Then make patching automatic so you don’t have to remember. Unattended-upgrades applies security updates on its own:
sudo apt install -y unattended-upgrades apt-listchanges needrestart
sudo dpkg-reconfigure -plow unattended-upgrades
Open /etc/apt/apt.conf.d/50unattended-upgrades and enable the two lines most people leave commented out — automatic reboot at a quiet hour (so a patched kernel actually takes effect) and a failure notification:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Mail "root";
Unattended-Upgrade::MailReport "on-change";
needrestart is the companion piece: after a library like libssl is patched, services still running the old code in memory need restarting. needrestart flags (or restarts) them so the fix is real, not just on disk.
Verify it worked:
sudo unattended-upgrades --dry-run --debug
# example output
Checking if system is running on battery is skipped. Please install powermgmt-base ...
Initial blacklist:
Initial whitelist (not strict):
Starting unattended upgrades script
Allowed origins are: origin=Ubuntu,archive=noble-security, ...
If you’re on Ubuntu, the free Ubuntu Pro tier (up to five machines) adds Livepatch, which applies kernel CVE fixes without a reboot — turn it on with sudo pro attach <TOKEN> and sudo pro enable livepatch.
Step 2 — Create a non-root user with sudo
Operating as root all day is a mistake: every process runs with full privileges, and root is the one account every bot on earth is trying to brute-force. Make a normal user and grant it sudo.
adduser deploy
usermod -aG sudo deploy
Copy your SSH setup over so you can log in as the new user (we’ll set up the key itself in the next step; if root already has an authorized_keys, this carries it across):
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/
Verify it worked — and do this before you touch SSH config. Open a second terminal on your own machine and confirm the new user can log in and escalate:
ssh deploy@YOUR_SERVER_IP
sudo whoami
# example output
root
If that prints root, you’re safe to proceed. If it doesn’t, fix it now while your original root session is still open.
Step 3 — Set up SSH key authentication
Keys replace passwords with a cryptographic pair: a public key on the server, a private key that never leaves your machine. This alone makes password brute-forcing — the single most common attack against your box — structurally impossible. Ed25519 is the modern default: short, fast, and strong.
Generate the key on your own machine, not the server:
ssh-keygen -t ed25519 -a 100 -C "deploy@your-laptop"
Copy the public key to the server:
ssh-copy-id deploy@YOUR_SERVER_IP
# or, if ssh-copy-id isn't available, paste ~/.ssh/id_ed25519.pub into
# /home/deploy/.ssh/authorized_keys on the server (one key per line)
Test it before going further: open a new session and confirm you log in without being asked for the account password (you may be asked for your key’s passphrase, which is different):
ssh deploy@YOUR_SERVER_IP
Keys, passwords, or passkeys?
For most servers an Ed25519 key with a strong passphrase is plenty. If you’re securing infrastructure that matters, consider a hardware-backed FIDO2 key (a YubiKey or similar). The private key physically lives on the token and every login requires a touch, so a compromised laptop still can’t authenticate:
ssh-keygen -t ed25519-sk -O verify-required -C "deploy@yubikey"
This needs OpenSSH 8.4+ on both ends. One gotcha: macOS’s bundled SSH isn’t built with FIDO2 support, so run brew install openssh if you use -sk keys from a Mac.
Step 4 — Harden sshd: disable root login and passwords
Now lock the front door. On Ubuntu 24.04 and Debian 12+, don’t edit /etc/ssh/sshd_config directly — put your changes in a drop-in file under /etc/ssh/sshd_config.d/. Drop-ins survive package upgrades, and the main config pulls them in right at the top.
Here’s the gotcha that catches people, and it caught us: for any given option, the first value wins, and the files are read in alphabetical order. Cloud images — Hetzner’s included — usually ship a 50-cloud-init.conf in that directory that already sets things like PasswordAuthentication. If your file sorts after it, your setting is silently ignored and you’ll swear you disabled password auth when you didn’t. So we name ours to sort first (00-), and we always confirm with sshd -T at the end rather than trusting the file.
sudo tee /etc/ssh/sshd_config.d/00-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
AuthenticationMethods publickey
MaxAuthTries 3
MaxSessions 4
LoginGraceTime 20
AllowUsers deploy
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
GSSAPIAuthentication no
ClientAliveInterval 300
ClientAliveCountMax 2
LogLevel VERBOSE
EOF
What each of the important ones buys you:
PermitRootLogin no— bots can no longer even attempt the account they most want.PasswordAuthentication no— kills brute-forcing outright; only your key gets in.MaxAuthTries 3andLoginGraceTime 20— shrink the unauthenticated window. This isn’t cosmetic: the 2024 regreSSHion vulnerability (CVE-2024-6387) was an unauthenticated root RCE that hinged on the login-grace timeout path. Patching to a fixed OpenSSH is the real fix — Qualys’s config stopgap at the time was actuallyLoginGraceTime 0— and keeping the window short is sensible hygiene on top of that.AllowUsers deploy— an explicit allow-list of who may even try to log in.
Always validate the config before reloading, or a typo can lock you out:
sudo sshd -t && sudo systemctl reload ssh
Verify it worked — check that the running daemon reflects your settings, and confirm root and password logins are actually refused:
sudo sshd -T | grep -Ei 'permitroot|passwordauth|pubkey|maxauth|logingrace|allowusers'
# example output
permitrootlogin no
pubkeyauthentication yes
passwordauthentication no
maxauthtries 3
logingracetime 20
allowusers deploy
Keep that second session open until you’ve opened a fresh one and confirmed you can still get in as deploy.
A note on changing the SSH port. Moving SSH off port 22 is a popular “tip,” but be honest about what it does: it cuts log noise, because the ~99% of bots that only scan port 22 stop hitting you. It is not a security control — a full port scan finds your SSH daemon in seconds regardless. Treat it as optional log hygiene, never as protection. And if you do it on Ubuntu 24.04, note that
ssh.socketnow owns the listening port — aPortline insshd_configis silently ignored. You have to edit the socket unit (or disable socket activation), and open the new port in both your host and network firewalls before you disconnect.
Step 5 — Enable a default-deny firewall (UFW)
Deny everything inbound by default, then open only the ports you actually serve. On Ubuntu and Debian we reach for UFW — a thin, readable front-end over the kernel’s nftables, and the right level of abstraction for a single server.
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # allow SSH BEFORE enabling — or you lock yourself out
sudo ufw limit 22/tcp # rate-limit repeated SSH connections
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
The single most common self-inflicted lockout is running ufw enable before allowing SSH. Don’t. ufw limit is a nice extra: it temporarily blocks any IP that makes six or more connections in 30 seconds, which knocks down casual brute-forcing on its own.
Verify it worked:
sudo ufw status verbose
# example output
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
22/tcp LIMIT Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
22/tcp (v6) LIMIT Anywhere (v6)
Don’t forget IPv6 — if your box has a v6 address, confirm the rules cover it (they show as (v6) above). Use UFW or raw nftables, never both at once, or you’ll fight conflicting rulesets.
Step 6 — Block brute-force attacks with fail2ban
Key-only auth already stops the logins, but bots will still hammer the port, filling your logs and burning a little CPU. A ban tool watches the auth log and firewalls off IPs that misbehave.
Install fail2ban and create a local jail config (never edit the packaged jail.conf directly):
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
backend = systemd
banaction = nftables-multiport
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 YOUR_HOME_IP
[sshd]
enabled = true
EOF
sudo systemctl enable --now fail2ban
Set ignoreip to include your own address so you never ban yourself during a deploy loop.
Verify it worked:
sudo fail2ban-client status sshd
# example output
Status for the jail: sshd
|- Filter
| |- Currently failed: 3
| |- Total failed: 418
| `- Journal matches: _SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
|- Currently banned: 6
|- Total banned: 51
`- Banned IP list: 203.0.113.7 198.51.100.42 203.0.113.91 ...
(The IPs above are documentation-range placeholders — yours will be real ones caught in the act.)
fail2ban or CrowdSec?
fail2ban is the reliable, battle-tested default. The newer alternative, CrowdSec, adds crowd-sourced threat intelligence: it detects locally and shares signals across a community, so you can block IPs that are attacking other people before they reach you.
| fail2ban | CrowdSec | |
|---|---|---|
| Model | Local log-parse → local ban | Local detection + crowd-sourced blocklist |
| Maturity | Since 2004, everywhere | Since 2020, fast-growing |
| Intelligence | Only what your box sees | Millions of contributors |
| Enforcement | Built in | Separate “bouncer” component |
| Setup | One config file | Agent + bouncer + collections |
For a single box where you want dead-simple and local, fail2ban is perfect. For internet-facing servers where the community blocklist earns its keep, CrowdSec is the more modern choice. There’s no urgency to migrate a working fail2ban install — pick one and move on.
Step 7 — Reduce the attack surface
You can’t attack a service that isn’t running. See what’s actually listening on the network:
ss -tlnp
# example output
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",...))
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",...))
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("postgres",...))
Anything listening on 0.0.0.0 (all interfaces) that doesn’t need to face the internet should either be disabled or bound to 127.0.0.1. Databases are the classic offender — Postgres and Redis should listen only on localhost. Then disable anything you don’t need; a print server or an mDNS responder has no business on a headless VPS:
systemctl list-unit-files --state=enabled
# disable whichever of these actually exist on your box (the redirect
# just swallows "unit not found" for the ones that were never installed):
sudo systemctl disable --now avahi-daemon cups rpcbind 2>/dev/null
The goal state: nothing on a public interface except SSH and the services you deliberately serve.
Step 8 — Kernel and network hardening (sysctl)
A set of kernel tunables closes off spoofing, redirect attacks, and information leaks. Many are already sane defaults on Ubuntu 24.04, but setting them explicitly documents intent and satisfies audit tools:
sudo tee /etc/sysctl.d/99-hardening.conf >/dev/null <<'EOF'
net.ipv4.conf.all.rp_filter = 1
net.ipv4.tcp_syncookies = 1
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
net.ipv4.conf.all.log_martians = 1
kernel.randomize_va_space = 2
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
EOF
sudo sysctl --system
One thing to watch: strict reverse-path filtering (rp_filter = 1) can drop legitimate traffic on hosts with asymmetric routing or multiple interfaces. If that’s you, use 2 (loose mode). Change one value at a time and test — over-tightening the kernel is a great way to create a mysterious outage you end up debugging at 2 a.m.
Step 9 — Put admin access behind a VPN (optional)
The strongest move for management access is to not expose SSH to the whole internet at all. Instead, run a WireGuard VPN and allow SSH only from the VPN’s internal subnet (via AllowUsers/Match Address in sshd and a tightened firewall rule). Now brute-force bots literally cannot reach your SSH port — they can’t route to it. This is more setup than most single-box projects need, but for anything holding real data it’s the highest-leverage step after key-only auth. WireGuard is fast, modern, and small enough to audit.
Step 10 — Log, audit, and verify
You want a before-and-after number you can actually point to. Two tools give you one.
auditd records security-relevant events — changes to user accounts, sudoers, and SSH config:
sudo apt install -y auditd audispd-plugins
sudo systemctl enable --now auditd
A minimal starting ruleset in /etc/audit/rules.d/hardening.rules:
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/ssh/sshd_config -p wa -k sshd
Load it with sudo augenrules --load, and review activity later with sudo aureport --auth.
Lynis gives you a single number to track progress. Run it before and after hardening and watch the score move:
sudo apt install -y lynis
sudo lynis audit system
# example output
Hardening index : 67 [############ ]
Tests performed : 258
...
Lynis security scan details:
Suggestions:
- Configure password hashing rounds in /etc/login.defs [AUTH-9230]
- Install a file integrity tool to monitor changes [FINT-4350]
A fresh, unhardened server typically lands somewhere around 55–65; after a pass like this one you should be comfortably into the 75+ range. Treat the Hardening Index as a progress metric between your own runs, not an absolute grade — chasing 100 usually means breaking something useful. Re-run it monthly and after any big change.
Ubuntu 24.04 vs Debian: what differs
The checklist is the same; a few details aren’t:
- SSH socket activation is an Ubuntu 24.04 thing —
ssh.socketowns the port and makes the oldPortdirective silently ineffective. Debian 12 still uses the classicssh.service, wherePortworks as expected. - Ubuntu Pro / Livepatch is Ubuntu-only. On Debian, lean on unattended-upgrades plus a scheduled reboot for kernel patches.
- AppArmor ships enabled on both, but Ubuntu has more profiles out of the box. Check with
sudo aa-status.
Everything else — the sudo user, keys, sshd drop-ins, UFW, fail2ban, sysctl — is identical.
The complete Linux VPS hardening checklist
Copy this into your runbook:
- Provider/network firewall configured before boot (SSH + 80/443 only)
- System fully updated; unattended-upgrades + needrestart enabled
- Non-root sudo user created and tested in a second session
- Ed25519 (or FIDO2) SSH key working
- Root login and password auth disabled via
sshd_config.ddrop-in -
sudo sshd -Tconfirms the hardened settings are live - UFW default-deny active; SSH allowed before enabling; IPv6 covered
- fail2ban (or CrowdSec) running and banning
-
ss -tlnpshows nothing unexpected listening publicly; DBs bound to localhost - sysctl hardening applied
- (Optional) SSH reachable only via VPN
-
auditdrunning; Lynis score recorded before/after; monthly re-audit scheduled
Frequently asked questions
Is a VPS secure by default? No. Default images typically ship with root SSH enabled, no firewall enforcement, and packages that may already have known CVEs. A fresh VPS is exposed to automated attacks within minutes of getting a public IP, so hardening is step one, not an optional extra.
How often should I update my VPS? Security patches should apply as soon as they’re available — automate this with unattended-upgrades. Do a manual review of the full system weekly, and a broader audit (including a Lynis re-scan) monthly. Reboot promptly after kernel updates, or use Livepatch to avoid the reboot.
Do I need fail2ban if I already use SSH keys? Keys stop unauthorized logins, but they don’t stop bots from hammering the port, filling your logs, and burning resources. fail2ban (or CrowdSec) bans the offending IPs, which keeps logs readable and cuts noise. They solve different problems, so run both.
Should I change the default SSH port? It’s optional log hygiene, not security. Moving off port 22 hides you from the ~99% of bots that only scan 22, so your logs get quieter — but a real attacker’s port scan finds SSH anywhere in seconds. Do it if you like clean logs; never rely on it as protection.
How do I know if my VPS has been hacked?
Warning signs include unfamiliar processes or high load, logins from unknown IPs in your auth log (journalctl -u ssh, or /var/log/auth.log where it exists), user accounts you didn’t create, unexpected outbound network traffic, and changed system binaries. auditd, a Lynis scan, and periodic rkhunter/chkrootkit checks help surface these early.
Can hardening lock me out of my VPS?
Yes — the two classic mistakes are enabling the firewall before allowing SSH, and disabling password auth before confirming key login works. Both are avoidable: keep a second SSH session open while you work, validate sshd config with sudo sshd -t before reloading, and know where your provider’s web console is as a backstop.
This walkthrough reflects the hardening pass we run on new Linux servers at Kage System. Commands were written against Ubuntu 24.04 LTS and Debian 12/13; the terminal outputs shown are representative examples using documentation-range IP addresses, not captures of any specific customer system. Verify each step in your own environment, and when in doubt, keep that second session open.