A server you've just been handed is on the internet from the very first minute. The bots that constantly scan port 22 for weak passwords won't wait for you to finish setting up; look at /var/log/auth.log a few hours after launch and you'll see the failed login attempts piling up.

The good news is that most of that risk goes away with a handful of simple, repeatable steps. This guide walks through them in order: updates, a non-root user, key-only SSH login, a firewall, automatic security updates, fail2ban and time settings. Every command is written for Ubuntu 24.04 and assumes you're currently logged in as root or as the user your hosting provider created.

Short answer: To harden a fresh Ubuntu 24.04 server, update all packages, create a non-root user with sudo, and configure SSH to accept keys only, with root login disabled. Then enable ufw with SSH and ports 80 and 443 open, turn on unattended-upgrades for automatic security patches, install fail2ban, and check the time zone and clock sync. All of it takes less than an hour.

First things first: update

The images hosting providers use are often weeks or months behind on updates. Before anything else, upgrade the packages:

bash
apt update
apt full-upgrade -y

If /var/run/reboot-required exists afterwards, the kernel or a core library has changed. Reboot now, while nothing is running on the server yet:

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

A non-root user with sudo

Working as root day to day means every typo runs with full privileges. Instead, create an ordinary user and elevate with sudo only when needed. This guide calls the user deploy; pick any name you like.

bash
adduser deploy
usermod -aG sudo deploy

adduser asks for a password. It won't be used for SSH logins, but sudo will ask for it, so choose a strong one and keep it in your password manager.

Key-only SSH login

Passwords can be guessed; an Ed25519 key, for all practical purposes, cannot. The goal of this section is to shut off password logins and direct root logins entirely.

Create and copy a key

If you don't have a key on your own machine yet, create one (run this on your laptop, not the server):

bash
ssh-keygen -t ed25519 -C "laptop"

If your provider installed your key for root when the server was created, the simplest approach is to copy it to the new user. On the server, as root:

bash
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

If password login is still enabled, running ssh-copy-id deploy@SERVER_IP from your laptop does the same thing. Now, in a new terminal, confirm that ssh deploy@SERVER_IP logs you in without a password and that sudo -v works. Don't go any further until it does. For more on keys, see connecting over SSH and creating a key.

A drop-in file for sshd

On Ubuntu 24.04, the main /etc/ssh/sshd_config includes every /etc/ssh/sshd_config.d/*.conf file in its first few lines. Rather than editing the main file, put your settings in a separate file; that way, upgrades to the openssh-server package won't prompt you about a modified config.

One subtle point: for each option, sshd applies the first value it reads, not the last1. On many cloud servers, cloud-init creates a file such as 50-cloud-init.conf containing PasswordAuthentication yes. To make sure your settings win, give your file a name that sorts earlier alphabetically:

/etc/ssh/sshd_config.d/00-hardening.conf
# Only key-based logins, never as root
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey

# Fewer chances per connection, shorter idle handshakes
MaxAuthTries 3
LoginGraceTime 30

# Nothing on this server needs X11
X11Forwarding no

# Only these users may log in over SSH
AllowUsers deploy

The AllowUsers line is both the most powerful and the most dangerous line in this file: any user not listed here can't log in, even with the right key. Spell the name exactly.

Before applying it, check the syntax and see which values sshd actually picked up:

bash
sudo sshd -t
sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|allowusers)'

The second command should print permitrootlogin no, passwordauthentication no and kbdinteractiveauthentication no. If you still see yes, another file is setting that option earlier.

Before you reload: keep a second session open

This is the single most important piece of advice in this guide. Before reloading sshd, open a second SSH session and keep it open until you're done. Reloading, or even restarting, the SSH service doesn't drop existing connections, so if the configuration is wrong, that open session is your only way to fix it without resorting to your provider's emergency console.

bash
sudo systemctl reload ssh

On Ubuntu the service is called ssh, not sshd. Now test from a third terminal:

bash
# Must work
ssh deploy@SERVER_IP

# Must fail with "Permission denied (publickey)"
ssh root@SERVER_IP
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password deploy@SERVER_IP

Close the earlier sessions only once all three results are what you expected.

Firewall with ufw

ufw is installed on Ubuntu but not enabled. Set the default policy to "deny all incoming, allow all outgoing" and open only SSH and web traffic. Order matters: allow SSH first, then enable ufw, or you'll lock yourself out.

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

OpenSSH is a predefined profile that opens port 22; list the profiles with sudo ufw app list. If your web server supports HTTP/3, open 443/udp as well.

Two things that catch people out:

  • Ports Docker publishes with -p bypass ufw, because Docker writes its own iptables rules directly. If you run Docker, publish internal services on 127.0.0.1 only.
  • If your provider also offers a cloud firewall, keep both layers consistent; having both is fine, and even better.

Automatic security updates

Most breaches exploit vulnerabilities whose patches were released long ago. The unattended-upgrades package exists for exactly this: it installs security patches every day on its own. It's usually preinstalled on Ubuntu 24.04, but make sure:

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

The second command creates or updates the following file, which should contain:

/etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

Some patches, such as kernel updates, don't take effect until a reboot. If your server can tolerate a few minutes of downtime in the middle of the night, turn on automatic reboots. Instead of editing 50unattended-upgrades, put your changes in a separate file that's read after it:

/etc/apt/apt.conf.d/52unattended-upgrades-local
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
Unattended-Upgrade::Remove-Unused-Dependencies "true";

04:00 is in the server's time zone, which is why the time settings below matter. To check that everything is configured correctly:

bash
sudo unattended-upgrade --dry-run --debug

Run logs are written to /var/log/unattended-upgrades/.

fail2ban for SSH

With password login disabled, password guessing no longer works. So fail2ban is mostly about reducing noise and wasted load here: IPs with repeated failed attempts get banned for a while, and your logs stay readable.

bash
sudo apt install -y fail2ban python3-systemd

Don't edit jail.conf; package updates may overwrite it. Put your settings in jail.local:

/etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
# Never ban localhost; add your own static IP here if you have one
ignoreip = 127.0.0.1/8 ::1

[sshd]
enabled = true
backend = systemd

With backend = systemd, logs are read straight from the journal, so you don't depend on any particular log file existing. Enable the service and check its status:

bash
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd

If you ever ban your own IP, run this from your provider's console or from another server:

bash
sudo fail2ban-client set sshd unbanip 203.0.113.7

Time zone and clock sync

An accurate clock isn't cosmetic: TLS certificates, time-limited tokens, log ordering and cron schedules all depend on it. Check the current state:

bash
timedatectl

There are two sensible choices for the time zone. UTC is better for servers that serve several regions or whose logs are compared with other servers; local time is more convenient for a server whose users are in one region and whose cron jobs are tied to business hours. What matters is choosing deliberately:

bash
sudo timedatectl set-timezone Europe/Berlin
# or keep it universal:
# sudo timedatectl set-timezone Etc/UTC

Ubuntu 24.04 uses systemd-timesyncd for clock synchronisation. Make sure it's on and the clock is synced:

bash
sudo timedatectl set-ntp true
timedatectl show -p NTPSynchronized
timedatectl timesync-status

The second command should print NTPSynchronized=yes. If it doesn't, your provider's firewall is probably blocking outbound UDP on port 123.

First-hour checklist

Keep this list handy and run through it from top to bottom for every new server:

  1. apt update && apt full-upgrade, plus a reboot if needed.
  2. Create a non-root user and add it to the sudo group.
  3. Copy the public key to that user's ~/.ssh/authorized_keys, with 700 and 600 permissions.
  4. Test key login and sudo in a new terminal.
  5. Create /etc/ssh/sshd_config.d/00-hardening.conf and verify it with sshd -t and sshd -T.
  6. Keep a second session open, then run systemctl reload ssh.
  7. Confirm that root and password logins are rejected.
  8. ufw: allow OpenSSH first, then 80 and 443, then enable.
  9. unattended-upgrades enabled, and a decision made about automatic reboots.
  10. fail2ban with jail.local, checked with fail2ban-client status sshd.
  11. A deliberately chosen time zone and NTPSynchronized=yes.

Frequently asked questions

Does changing the SSH port from 22 improve security?

Changing the port only reduces bot noise in your logs; it isn't a substitute for real security. Once password and root logins are disabled and only keys are accepted, password guessing on port 22 is effectively useless. If you do change the port, open the new one in ufw before reloading.

What if I lock myself out after changing SSH settings?

If you kept a second session open, fix 00-hardening.conf from there and run systemctl reload ssh again. If no session is open, your only option is your hosting provider's emergency (web) console.

Do I still need fail2ban with key-only SSH login?

It isn't essential, because password guessing no longer works. But fail2ban temporarily bans IPs with repeated failed attempts, which cuts down on wasted load and log volume.

Why doesn't ufw block Docker ports?

For ports published with -p, Docker writes its own iptables rules directly, and those rules bypass ufw. Publish internal services such as databases on 127.0.0.1 only.

Should my server's time zone be UTC or local time?

UTC is better for servers whose logs are compared with other servers or whose users span several regions. If your users are in one region and cron jobs are tied to business hours, local time is more convenient; what matters is choosing deliberately and keeping NTP on.

Wrap-up

None of these steps is complicated, and together they take less than an hour, yet they move your server off the list of easy targets. If you remember only one thing, make it this: before changing any SSH or firewall setting, keep a way back in open. The next step, and a topic of its own, is a backup you can actually restore from.

  1. The sshd_config man page states that for each keyword, the first obtained value is used. See man sshd_config. ↩