A fresh Ubuntu box boots fine. It’s also wide open, which nobody tells you. So here’s the run-through I do on every new 24.04 LTS VPS before I trust it with anything that matters. SSH locked to keys on a port the scanners aren’t camped on. UFW with the fewest rules I can get away with. fail2ban swatting the brute-force bots, unattended security updates ticking over, AppArmor actually enforcing instead of just sulking in complain mode, and auditd so that six months from now I can answer “who touched what” without guessing. It’s 30 commands. You paste them in order, and after each one I hand you the check that proves it took. Block out 30 to 45 minutes. By the end, the endless background hum of automated attacks just bounces off.
The 10-step checklist
- First root access and admin user creation
- Initial system updates
- SSH hardening (keys, custom port, no root login)
- UFW firewall with minimal rules
- fail2ban against SSH brute-force
- Automatic updates (unattended-upgrades)
- AppArmor in enforce mode
- Audit logs with auditd
- Time synchronisation and NTP
- Final verification and snapshot
- FAQ
First root access and admin user creation
Log in as root, just this once. Then build yourself a plain user for the day-to-day and drop it in the sudo group. Living as root full-time? That’s how one stray typo wipes the wrong thing while you’re still reaching for the coffee.
# On your local machine ssh root@YOUR_IP # On the server (as root) adduser admin # create the admin user usermod -aG sudo admin # add to sudo group mkdir -p /home/admin/.ssh chmod 700 /home/admin/.ssh cp /root/.ssh/authorized_keys /home/admin/.ssh/ chown -R admin:admin /home/admin/.ssh
Initial system updates
A brand-new server usually shows up with 20 to 60 packages already stale. Patch the lot before you install a single thing of your own. You don’t want to build on top of holes that already have public exploits.
apt update apt upgrade -y apt dist-upgrade -y apt autoremove -y apt autoclean
See a “Pending kernel upgrade” message? Reboot now (reboot), wait for it to come back, then carry on. Soldiering ahead on the old kernel just stores up a weird problem for later.
SSH hardening (keys, custom port, no root login)
This is the big one. It’s also the step that’ll lock you out cold if you rush it. A few edits to /etc/ssh/sshd_config: kill direct root login, switch password auth off entirely so it’s keys or nothing, then move SSH off port 22 to somewhere the scanners aren’t hammering around the clock.
# Backup the config before editing cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak # Edit the config nano /etc/ssh/sshd_config # Lines to set or add: Port 2222 # custom port (pick any 1024-65535) PermitRootLogin no # no more direct root login PasswordAuthentication no # SSH keys only PubkeyAuthentication yes MaxAuthTries 3 # 3 attempts max per connection ClientAliveInterval 300 # auto-disconnect after 5 min ClientAliveCountMax 2 LoginGraceTime 30 AllowUsers admin # restrict to the admin user only
Validate the syntax before you restart sshd. Always. One bad line in that file and the restart kicks you off with no way back in:
sshd -t # No output means OK; if there is one, fix it before continuing # Restart SSH systemctl restart ssh
# From your local machine, in a new terminal ssh admin@YOUR_IP -p 2222
UFW firewall with minimal rules
UFW is just a friendlier face glued onto nftables/iptables. It’s the one I reach for on nearly every box. The whole idea fits in one breath: slam the door on everything inbound, then crack open only the few ports you genuinely need, one at a time, by hand.
apt install -y ufw ufw default deny incoming ufw default allow outgoing # Open the custom SSH port (mandatory before enabling) ufw allow 2222/tcp comment 'SSH custom port' # Open application ports as needed ufw allow 80/tcp comment 'HTTP' ufw allow 443/tcp comment 'HTTPS' # Enable the firewall ufw enable # answer y
Database behind the app? Postgres, MySQL, a Redis cache, whatever it is. Never hang 5432, 3306 or 6379 out on the open internet. An unauthenticated Redis goes from “public” to “owned” in hours, sometimes less, and I’ve watched it happen. Tunnel into it over SSH from the app side, or wire up a WireGuard link. Just keep it off the public interface.
fail2ban against SSH brute-force
Keys-only SSH doesn’t stop the bots from trying. They keep knocking by the thousand, and every rejected attempt still burns a sliver of CPU while it junks up your logs. fail2ban watches for any IP that fails too many times inside a window, then drops a firewall ban on it without you lifting a finger. You set the thresholds once and mostly never look again.
apt install -y fail2ban # Create the local config (jail.local takes precedence over jail.conf) cat > /etc/fail2ban/jail.local <<'EOF' [DEFAULT] bantime = 3600 findtime = 600 maxretry = 3 ignoreip = 127.0.0.1/8 ::1 [sshd] enabled = true port = 2222 backend = systemd EOF systemctl enable --now fail2ban
Banned your own IP after fumbling the login a few times? Happens to all of us. Spring it loose with fail2ban-client set sshd unbanip 1.2.3.4.
Automatic updates (unattended-upgrades)
Ubuntu ships security fixes a couple of times a week, and nobody sane is SSHing in to run apt upgrade by hand every other day. So you let it patch the security stuff on its own while the big distro upgrades, the kind that genuinely break things, stay off the table. Honestly, I think this is the highest payoff-per-minute thing on the whole list. I might be talking myself into it because it’s so little effort, but the math has held up for me across years of boxes.
apt install -y unattended-upgrades apt-listchanges # Enable Ubuntu's default config dpkg-reconfigure -plow unattended-upgrades # Answer Yes to the question # Verify the config file nano /etc/apt/apt.conf.d/50unattended-upgrades
Open 50unattended-upgrades and double-check the security sources are really switched on:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
AppArmor in enforce mode
Mandatory Access Control on Ubuntu goes through AppArmor. Each service gets a profile spelling out exactly which files and syscalls it may touch, and anything that strays outside the box gets slapped down. It ships on 24.04 and it’s already running. The catch nobody mentions: a chunk of those profiles sit in “complain” mode, which logs the violation and then shrugs and lets it through anyway. So we flip them to “enforce” and give them teeth.
apt install -y apparmor apparmor-utils apparmor-profiles apparmor-profiles-extra # Current status aa-status # Switch all profiles to enforce aa-enforce /etc/apparmor.d/*
Something falls over the moment enforce kicks in? Don’t go ripping the whole subsystem out, that’s overkill. Pull up journalctl -u apparmor and it’ll tell you precisely what got denied. From there you either patch that one profile or, if you’re in a hurry, drop just that profile back to complain while you sort it: aa-complain /etc/apparmor.d/usr.sbin.service-name.
Audit logs with auditd
auditd is the flight recorder. It quietly logs the sensitive stuff: reads of critical files and every sudo command, plus the config changes you’d otherwise never catch. You won’t give it a second thought until the day someone asks “who edited /etc/passwd yesterday at 2pm?” and instead of shrugging, you just pull the exact answer.
apt install -y auditd audispd-plugins # Minimal rules: watch critical files cat > /etc/audit/rules.d/hardening.rules <<'EOF' # User account modifications -w /etc/passwd -p wa -k user_modification -w /etc/shadow -p wa -k user_modification -w /etc/group -p wa -k user_modification -w /etc/sudoers -p wa -k sudoers_modification -w /etc/sudoers.d/ -p wa -k sudoers_modification # SSH modifications -w /etc/ssh/sshd_config -p wa -k ssh_config # Abnormal network egress -a always,exit -F arch=b64 -S socket -F a0=10 -k network_socket # Lock the rules (no modification without reboot) -e 2 EOF systemctl enable --now auditd systemctl restart auditd
Need to read back what happened? ausearch -k user_modification pulls the events by tag. Or aureport -au if you just want the authentication summary.
Time synchronisation and NTP
People skip this one and then spend an afternoon baffled about why nothing works. Let the clock drift past a few seconds and TLS certs suddenly read as invalid, so your own outbound HTTPS calls bounce. On top of that, trying to line up these logs against your other machines turns into a guessing game. Good news: Ubuntu 24.04 already runs systemd-timesyncd, so this is really just pointing it at the right zone and confirming it’s awake.
timedatectl set-timezone Europe/Paris # or your time zone timedatectl set-ntp true # Verify timedatectl status
Final verification and snapshot
Before you sign off on this box as production-ready, fire the whole batch at once and actually read the output, top to bottom:
# Consolidated checks (";" separator so everything runs even if one command fails)
echo "=== SSH ===" ; grep -E "^(Port|PermitRoot|PasswordAuth)" /etc/ssh/sshd_config
echo "=== UFW ===" ; ufw status numbered | head -10
echo "=== fail2ban ===" ; fail2ban-client status sshd
echo "=== unattended-upgrades ===" ; systemctl is-active unattended-upgrades
echo "=== AppArmor ===" ; aa-status | head -3
echo "=== auditd ===" ; systemctl is-active auditd
echo "=== Updates ===" ; apt list --upgradable 2>/dev/null | wc -l
echo "=== Uptime ===" ; uptime
Once every line reads the way it should, go grab a server snapshot from your provider (OVH calls it “Backup snapshot”, Hetzner just “Snapshot”, AWS “AMI”, Scaleway “Snapshot” again). Two minutes, tops. What you get back is a clean, known-good image to roll to the day you inevitably break something down the road. I take one right here, every single time.
And if this box actually matters in production, this isn’t the finish line. Bolt on uptime monitoring (SecurityWatch or UptimeRobot) plus something that keeps eyes on the app and the host underneath it (Netdata, Grafana Cloud Free, or Prometheus + node_exporter). Harden a server and then never look at it again, and you’ll learn it died from an angry user, days after the fact.
Monitor security across multiple servers?
SecurityWatch watches uptime, TLS certs and HTTP headers across every target you give it, plus it flags config drift, all from the browser. When something slips, it pings you on Slack or Discord. Free, runs right in your browser, and the watchlist lives in localStorage.
Routine maintenance after hardening
Nobody wants to hear this part: all of it rots if you walk away. Here’s the rhythm I keep on a 24.04 LTS box in 2026. Every week, a quick glance at fail2ban-client status sshd and journalctl -p err -b. Every month, check aa-status, ufw status and apt list --upgradable. Every quarter, eyeball /etc/passwd for users you don’t recognise. Skim /etc/cron.d/ for jobs you never scheduled. Run last -n 50 and look hard at any login that feels off. Anything weird on that list isn’t a “remind me next month.” It’s a “drop what I’m doing and dig in today.”
FAQ
Why change the SSH port if I am already using keys?
It’s not one or the other. The port move stacks on top of your keys, it doesn’t stand in for them. Bots sweeping the whole IPv4 range fling something like 90 percent of their attempts straight at port 22, so kicking SSH up to a high port drags my logs from ten thousand failed hits a day down to a handful, and sshd quits burning cycles saying no. Is it security through obscurity? Yeah, totally, I won’t pretend it’s clever. But bolted onto keys it costs me nothing and it quietly works, so on it goes every time.
UFW or nftables directly, which to choose?
UFW, for pretty much everything. The config reads clean and the syntax is genuinely hard to fumble, and that covers a normal app server with room to spare. Go down to raw nftables only when you truly need the fancy stuff UFW can’t express, like port knocking or GeoIP filtering or really tight rate limiting. And honestly you don’t have to choose: drop raw rules into /etc/ufw/before.rules and you extend UFW without tearing it out.
Can automatic updates break my server?
It can. It very rarely does. Out of the box unattended-upgrades only pulls from the -security sources, so you’re getting critical patches and none of the big version jumps that actually wreck things. On an LTS the odds of it biting you are genuinely tiny. The one knob I do turn on anything important: kill the automatic reboot (Automatic-Reboot "false") and reboot the thing myself in a maintenance window, so it doesn’t drop mid-afternoon with users still on it.
AppArmor or SELinux?
On Ubuntu? AppArmor, no contest. Could you swap in SELinux instead? Sure, technically. But you’d rip out AppArmor and rewrite every profile from scratch first, and for most setups that’s a mountain of effort that gets you nothing real. SELinux earns its keep over on the RHEL side of the world (CentOS Stream, Rocky, and friends), where it’s the native choice and the whole distro is already wired around it. Just go with whatever your distro ships and expects.
Should I encrypt the disks of a VPS?
On a VPS, where someone else owns the hypervisor, LUKS buys you less than you’d hope. The key has to live somewhere, and that somewhere is usually decrypted in RAM the second the box boots, so a datacenter operator who can reach the storage can still read your data. Mostly theatre in that setting, honestly. Where disk encryption genuinely pulls its weight is bare metal, or a secondary data volume locked with dm-crypt and a key you keep well off the machine.
How do I audit the server 6 months after hardening?
A handful of free tools carry this, and they’re the only ones I bother with. Lynis (apt install lynis; lynis audit system) hands you a hardening score plus a punch list of everything that’s still soft. OpenSCAP with the “Standard System Security Profile for Ubuntu” comes out only when you’re in a regulated shop and need the paper trail. For sniffing out rootkits, that’s chkrootkit and rkhunter. I run Lynis once a quarter, and the rootkit scanners stay in the drawer until something actually feels wrong.













