Skip to main content
Server & DevOpsMay 23, 202612 min read

Hardening A Fresh Ubuntu 24.04 VPS In 15 Minutes

A fresh public Ubuntu 24.04 VPS sees SSH brute-force attempts within a minute of getting an IP. The 15-minute hardening run that closes the obvious doors before production traffic, with the verification commands.

SSH Brute Force Starts Before You Finish Your Coffee

A fresh Ubuntu 24.04 VPS exposed to the public internet starts receiving unsolicited SSH connection attempts within minutes of getting an IP. Not because you are interesting, but because the entire IPv4 space is scanned continuously by automated infrastructure. Honeypot research documents this as a constant background process rather than an event: SANS ISC honeypot diaries routinely record dozens of attempts in the first minutes of exposure, and long-term honeynet studies show the traffic never stops, it only varies in volume.

You will see figures like "30 seconds" quoted a lot, including in earlier versions of this article. The honest version is that it depends on your provider's IP range and how recently that block was scanned. What is not in dispute: it happens fast, it is automated, and it is relentless.

The 15-minute run below does not turn a VPS into a fortress. It closes the obvious doors so the baseline noise floor never becomes a successful login. Everything here runs on a freshly provisioned root-access VPS, in order, no skipping.

What Each Step Actually Buys You

Before the commands, the map. If you only have time for some of this, the top three rows are the ones that matter.

StepClosesIf you skip it
Key-only SSH, no root loginCredential guessing entirelyEvery bot on the internet gets unlimited guesses at your password
Firewall, default denyEverything you did not mean to exposeThat Redis you bound to 0.0.0.0 "temporarily" is now public
Automatic security updatesThe next CVE, unattendedYou patch when you remember, which is after the exploit is public
fail2banLog noise, slow credential attacksLogs full of junk, and a slow-drip attack never gets throttled
Kernel sysctlsSYN floods, spoofing, ICMP tricksCheap network-level attacks that a one-line setting would have stopped
Time syncTLS validation and log correlation breaking silentlyCertificates "randomly" invalid, logs impossible to correlate
auditdNot knowing what happened afterwardsAn incident with no forensic trail

Pre-Flight At Provisioning

Two decisions made at creation time save work later:

  • Provide an SSH key in the create-server form. Hetzner, AWS, Vultr, and DigitalOcean all accept one. The VPS boots with the key in /root/.ssh/authorized_keys and no password is ever set on root. This closes the window where a password-authenticated root account exists on a public IP.
  • Use ed25519, not RSA. If you are generating a key for this:
ssh-keygen -t ed25519 -a 100 -C "ops@yourcompany"

Ed25519 keys are shorter, faster, and have no key-size footgun. RSA is fine at 4096 bits but there is no reason to choose it for a new key in 2026.

  • Check whether your provider has a network-level firewall too. Hetzner Cloud Firewalls, AWS security groups, and DigitalOcean Cloud Firewalls filter before traffic reaches the VPS. UFW filters on the VPS. They are different layers and you want both: the cloud firewall is your outer wall and survives a misconfigured UFW, while UFW protects you if the cloud rule is loosened by someone else on your team. Do not treat one as a replacement for the other.

Now SSH in as root.

Step 1: Patch Everything First

Before changing any other configuration, apply pending security updates.

apt-get update
apt-get -y upgrade
apt-get -y install unattended-upgrades apt-listchanges
dpkg-reconfigure --priority=low unattended-upgrades

Now the step that most guides omit, and the reason plenty of servers think they are patching and are not. Verify it actually runs:

# Are the enable flags really set?
cat /etc/apt/apt.conf.d/20auto-upgrades
# Want:
#   APT::Periodic::Update-Package-Lists "1";
#   APT::Periodic::Unattended-Upgrade "1";

# Dry-run the real thing and watch what it decides
unattended-upgrade --dry-run --debug 2>&1 | tail -20

# After a day, confirm it has actually been running
grep -c . /var/log/unattended-upgrades/unattended-upgrades.log
systemctl status unattended-upgrades --no-pager

Installing the package is not the same as enabling it, and a silent no-op here means you are unpatched while believing otherwise. Check the log again a week later.

Reboot if the upgrade installed a new kernel:

[ -f /var/run/reboot-required ] && reboot

Step 2: Create A Non-Root User With Sudo

adduser --disabled-password ops
usermod -aG sudo ops
mkdir -p /home/ops/.ssh
cp /root/.ssh/authorized_keys /home/ops/.ssh/
chown -R ops:ops /home/ops/.ssh
chmod 700 /home/ops/.ssh
chmod 600 /home/ops/.ssh/authorized_keys

Test it in a second terminal before continuing:

ssh ops@<vps-ip>
sudo whoami   # should print "root"

Keep the original root session open until that works. This is not ceremony. The next step disables root login, and if the ops key is wrong you have just locked yourself out of a machine whose only recovery path is your provider's web console.

Step 3: Lock Down SSH

Drop a hardening file at /etc/ssh/sshd_config.d/99-hardening.conf:

PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
AuthenticationMethods publickey
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
LoginGraceTime 30
AllowUsers ops

AllowUsers ops is the line to think twice about. It is a hard allowlist: any account not named here cannot SSH in, including ones you create later. That is the point, but it also means a future you, adding a deploy user and wondering why its key is rejected, will spend twenty minutes on it. Note it somewhere.

Restart and verify:

systemctl restart ssh
sshd -T | grep -E "permitrootlogin|passwordauthentication|allowusers"

sshd -T prints the effective configuration after all includes are merged, which is why it is the right check rather than reading the file back. If those three show the hardened values, root login is closed and password auth is gone. Now drop the original root session.

Changing the SSH port is a separate decision. It is security by obscurity at best, useful only for cutting brute-force log noise. It stops zero targeted attackers. If you want quieter logs, change it. If you want hardening, everything else on this page matters more.

Step 4: Firewall With UFW

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
# Add only what the service actually needs:
# ufw allow 80/tcp
# ufw allow 443/tcp
ufw --force enable
ufw status verbose

If the application sits behind a load balancer or reverse proxy on another machine, do not open the app port to the world. Restrict it to the proxy:

ufw allow from 10.0.0.5 to any port 8080

The most common real-world failure here is not UFW itself, it is a service bound to 0.0.0.0 that someone later exposes at the cloud-firewall layer, assuming UFW will save them. Check what is actually listening (Step 8) rather than assuming.

Step 5: Fail2ban For The Brute-Force Floor

Even with key-only SSH, bots hammer port 22 forever. Fail2ban bans IPs that cross a threshold.

apt-get -y install fail2ban
cat > /etc/fail2ban/jail.d/sshd.local <<'EOF'
[sshd]
enabled = true
port = 22
maxretry = 3
bantime = 1h
findtime = 10m
EOF
systemctl enable --now fail2ban
fail2ban-client status sshd

Be clear about what this is and is not. With PasswordAuthentication no already set, fail2ban is not what stops a break-in, the key requirement is. Fail2ban buys you quieter logs and throttles slow-drip attempts that stay under the radar. It is worth the two minutes, but do not let it substitute for Step 3.

Step 6: Kernel Network Hardening

cat > /etc/sysctl.d/99-hardening.conf <<'EOF'
# Source IP spoofing protection
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# Ignore ICMP broadcast (smurf attack)
net.ipv4.icmp_echo_ignore_broadcasts = 1

# Ignore bogus ICMP responses
net.ipv4.icmp_ignore_bogus_error_responses = 1

# SYN flood mitigation
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 2048
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 5

# Disable source routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0

# Disable ICMP redirect acceptance
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv6.conf.all.accept_redirects = 0

# Log martian packets
net.ipv4.conf.all.log_martians = 1
EOF

sysctl --system

Verify they took, because a typo in a sysctl file fails quietly:

sysctl net.ipv4.tcp_syncookies net.ipv4.conf.all.rp_filter

One caveat on log_martians: it is useful for spotting spoofing, and it is also a way to fill a small disk if someone points a flood at you. If /var/log is tight, keep an eye on it or leave it off.

Step 7: Time Sync

A drifting clock breaks TLS validation, ruins log correlation, and silently breaks anything using timestamps for authentication (JWTs, signed API requests, TOTP).

apt-get -y install chrony
systemctl enable --now chrony
chronyc tracking

Last offset should be near zero after a minute. System time drifting by more than a second on a VPS usually means the host is oversubscribed, which is its own signal.

Step 8: Disable Services You Will Not Use

ss -tulpn
systemctl list-units --type=service --state=running

Read the ss output carefully: anything bound to 0.0.0.0 or [::] is listening on every interface, which is the thing you actually care about. A service on 127.0.0.1 is not exposed regardless of your firewall.

Ubuntu 24.04's defaults are reasonable, but check for avahi-daemon, cups, bluetoothd, and snapd:

systemctl disable --now snapd.socket snapd.service
apt-get -y purge snapd

Purging snapd is a preference, not a security requirement. If anything on the box needs snaps (some monitoring agents do), leave it.

Step 9: Basic Audit Logging

apt-get -y install auditd audispd-plugins
systemctl enable --now auditd

Defaults log identity changes, sudo activity, and filesystem modification to /var/log/audit/audit.log. Two things worth adding immediately, because the defaults will not tell you about the files that matter most:

cat > /etc/audit/rules.d/99-custom.rules <<'EOF'
# Watch authentication config
-w /etc/ssh/sshd_config -p wa -k sshd_config
-w /etc/ssh/sshd_config.d/ -p wa -k sshd_config
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
EOF
augenrules --load
auditctl -l

Now a change to your SSH config or sudoers is recorded with the user and timestamp. Search it later with ausearch -k sshd_config.

Audit logs are only useful if they leave the machine. An attacker with root deletes local logs. Ship them to a central collector as soon as you have one.

Step 10: A Periodic Reality Check

cat > /etc/cron.weekly/hardening-snapshot <<'EOF'
#!/bin/sh
{
  echo "=== Listening sockets ==="
  ss -tulpn
  echo
  echo "=== Recent successful logins ==="
  last -20
  echo
  echo "=== Recent SSH failures ==="
  journalctl -u ssh -n 50 --no-pager | grep -i fail
  echo
  echo "=== fail2ban status ==="
  fail2ban-client status sshd
  echo
  echo "=== Pending security updates ==="
  apt-get -s upgrade | grep -i security | head
} | mail -s "Weekly hardening snapshot $(hostname)" ops@example.com
EOF
chmod +x /etc/cron.weekly/hardening-snapshot

Requires mailutils and an SMTP relay. No outbound mail path? Write to a known directory and collect it on schedule instead. The point is that someone reads it: an unread weekly email is not a control, it is a filter rule.

Verification In Six Commands

sshd -T | grep -E "permitrootlogin|passwordauthentication"   # both "no"
ufw status verbose                                            # active, minimal rules
fail2ban-client status sshd                                   # running
chronyc tracking                                              # offset near zero
ss -tulpn                                                     # only expected listeners
cat /etc/apt/apt.conf.d/20auto-upgrades                       # both flags "1"

That last one is the addition worth keeping. It is the control most likely to be silently broken and the one nobody checks.

What This Does Not Cover

The 15-minute run is the baseline, not the destination:

  • Application-layer hardening (web server, database, cache, etc.)
  • Network segmentation across multiple servers
  • Centralised log aggregation and SIEM
  • Intrusion detection beyond fail2ban (Wazuh, OSSEC, Falco)
  • Backup automation and, more importantly, tested restores
  • Compliance frameworks (SOC 2, ISO 27001, PCI DSS)
  • Multi-factor authentication on SSH, which is worth adding for anything sensitive

Each is separate work, and the right configuration depends on the application.

Bottom Line

Fifteen minutes after first boot the VPS has key-only SSH, a default-deny firewall, fail2ban, kernel network hardening, verified automatic security updates, time sync, audit rules on the files that matter, and a weekly self-report. The automated background noise of the internet stops being a risk and becomes what it should be: noise.

The one habit that matters more than any command here: check the boring controls periodically. Unattended-upgrades silently not running is far more likely to hurt you than an exotic kernel exploit.

If you want this run automated across a fleet, version-controlled and audited, or layered with application-specific hardening for Magento, WordPress, Node.js, Kubernetes, or Postgres, that is what our servers management and security and compliance work covers.

Sources

Talk to the engineer who will own your stack.

No account managers, no offshore handoff. Senior DevOps, direct. Tell us what you are dealing with and you get a straight answer.