Harden a new Ubuntu server without locking yourself out

A deploy user, SSH keys only, UFW, fail2ban and unattended security upgrades, in the order that cannot lock you out. Tested on Ubuntu 24.04 and 26.04.

Jacob Søndergaard ·

A new VPS from any provider arrives with root, an SSH key and every port open. Hardening an Ubuntu 24.04 or 26.04 server takes about fifteen minutes, and the order is what keeps you from being locked out: every step below either adds a way in or leaves the existing one alone, except the last, which takes root’s away. By the time you run it, you have logged in as the new user twice.

Every command here was run, in this order, on fresh Ubuntu 24.04 and 26.04 servers. Commands run as root. If your provider logs you in as ubuntu instead, run sudo -i first and copy keys from /home/ubuntu/.ssh where the steps say /root/.ssh.

Before you start

Keep your first root session open until the very end. Every check below happens in a second terminal, so a mistake leaves you with a working shell to undo it from. Find your provider’s browser console now as well. It does not go through SSH, so it still works when SSH does not.

Bring the packages up to date, and reboot if the upgrade asks for it:

apt update && apt full-upgrade -y
ls /var/run/reboot-required   # if the file exists: reboot, then log back in as root

Create a deploy user and test the login first

adduser --disabled-password --comment "" deploy
usermod -aG sudo deploy
passwd deploy

install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
install -m 600 -o deploy -g deploy /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys

The account is created without a password and then given one with passwd. That password is only for sudo: once SSH is hardened, nobody can log in with it. Pick a long one and keep it in your password manager.

install copies root’s authorized keys and sets the owner and mode in the same step. The modes matter: sshd ignores an authorized_keys file that other users can write to, and the login fails with Permission denied (publickey) even though the key is right there.

Now, from a second terminal on your own machine:

ssh deploy@203.0.113.10
sudo whoami    # asks for the deploy password, prints: root

Do not go on until both work. If the login fails, fix it from the root session, which is still open.

UFW: default deny, then 22, 80 and 443

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw status verbose

The allow rule for 22 goes in before enable. Enabling first, on a firewall that denies by default, drops new SSH connections, and the only way back is the provider console. --force skips the prompt that warns about exactly that, which the rule above has already dealt with. Ports 80 and 443 are for Nginx and certificate validation. Leave them out on a database server that serves no HTTP.

Open a new SSH session as deploy to confirm the firewall still lets you in. A provider firewall, such as a Hetzner Cloud firewall or a DigitalOcean cloud firewall, sits in front of this one. Both apply, so a port has to be open in each.

fail2ban for SSH

apt install -y fail2ban
/etc/fail2ban/jail.d/sshd.local
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
systemctl enable fail2ban
systemctl restart fail2ban
fail2ban-client status sshd

Ubuntu’s package already enables the SSH jail and reads sshd’s log from the systemd journal, so this file mainly sets the thresholds: five failures within ten minutes bans the address for an hour. The ban is an nftables rule, and status sshd shows the counters and the banned addresses.

Your own mistakes count. A ban blocks port 22 for the address entirely, so a working key gets Connection refused rather than a login. A refused root login logs two failures on its own, so the checks at the end of this post, plus one or two typos, are enough to ban your laptop. If your address is static, exempt it by adding one line under [sshd] and restarting fail2ban:

ignoreip = 127.0.0.1/8 ::1 198.51.100.7

If you are banned already, wait out the hour, or lift it from the provider console with fail2ban-client set sshd unbanip 198.51.100.7. Restarting fail2ban does not help: it keeps bans in its database and puts them back on start.

Unattended security upgrades

Ubuntu’s server images ship with unattended-upgrades installed and switched on. Check rather than assume, since some provider images change it:

cat /etc/apt/apt.conf.d/20auto-upgrades
systemctl list-timers 'apt-daily*'
unattended-upgrade --dry-run --debug 2>&1 | tail -5

20auto-upgrades should hold both lines below, with "1". If the file is missing, write it yourself. dpkg-reconfigure unattended-upgrades will not recreate a deleted file; it prints Not replacing deleted config file and does nothing.

/etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
apt install -y unattended-upgrades
systemctl enable --now unattended-upgrades

The upgrade itself runs from apt-daily-upgrade.timer once a day. The default policy in 50unattended-upgrades takes packages from the release’s -security pocket only, so a new PHP or Nginx minor version from -updates waits for you. Packages from a PPA or another third-party repository are never upgraded until you add that repository’s origin; the Laravel server post shows how for the PHP repositories.

Kernel and libc updates install but take effect only after a reboot, which nothing does for you. Either watch for /var/run/reboot-required, or let the server reboot itself at a quiet hour:

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

Swap on a small VPS

Cloud images come without swap. On a 2 or 4 GB server, a composer install or an npm run build beside a running database is enough to wake the out-of-memory killer, and it kills the largest process, which is usually MySQL. A swap file turns that into a slow minute instead.

fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
systemctl daemon-reload
swapon --show

2 GB suits a server with 2 to 4 GB of memory. If fallocate is refused by the filesystem, dd if=/dev/zero of=/swapfile bs=1M count=2048 creates the same file more slowly. The fstab line brings the swap back after a reboot; daemon-reload stops systemd warning that its copy of fstab is stale.

SSH: keys only, and root login last

Everything so far left root’s login alone. This step removes it, along with password logins for every account, so it runs after the deploy user’s key login is proven and after the firewall has shown it lets SSH through. Prove the login once more in a new terminal, then write the drop-in file:

/etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes

The 00- prefix matters. sshd keeps the first value it reads for each setting, sshd_config includes the drop-in directory at its top, and the files in it are read in name order. On a server created with password login enabled, cloud-init writes a 50-cloud-init.conf containing PasswordAuthentication yes, and a file named 99-hardening.conf loses to it, and the server keeps accepting passwords while the file you wrote says otherwise. Check what sshd will actually use before you reload:

sshd -t
sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication)'
systemctl reload ssh

sshd -t exits silently when the configuration is valid. sshd -T prints the merged result and should show permitrootlogin no and passwordauthentication no. Then, from your own machine, with the root session still open:

ssh deploy@203.0.113.10        # logs in
ssh root@203.0.113.10          # Permission denied (publickey).
ssh -o PubkeyAuthentication=no deploy@203.0.113.10   # Permission denied (publickey).

Those two refusals log three of fail2ban’s five failures, so run each once. If the first command fails, delete 00-hardening.conf from the root session, run systemctl reload ssh, and work out what is wrong with the deploy user before trying again. Only once all three behave as shown, close the root session.

Changing the SSH port

A non-standard port cuts log noise, not risk, so it is not in this list. On 24.04 and 26.04 the listening socket belongs to ssh.socket, not sshd: after setting Port 2222, systemctl reload ssh leaves it on 22. systemctl daemon-reload followed by systemctl restart ssh.socket moves it, and closes 22 in the same moment, so add the UFW rule for the new port first.

What Ubuntu 26.04 changes

The sequence above is identical on both releases. Three things around it are not.

  • sudo is sudo-rs. sudo --version prints sudo-rs, and the password prompt reads [sudo: authenticate] Password:. It implements a subset of sudo’s options. sudo -E prints a warning and runs the command with the environment discarded rather than preserved, without failing, so a script under set -e carries on with every variable empty. Name the variables instead: sudo --preserve-env=APP_ENV,DB_HOST command works on both releases.
  • coreutils are the Rust uutils. install --version prints uutils coreutils. Most commands behave the same. One that bites in provisioning scripts: install -d -m 0700 a/b under umask 077 creates the intermediate directory a as 0700, where GNU install on 24.04 makes it 0755. Anything that has to traverse a then fails with an error naming a file two levels further down. Create each level with its own mode.
  • OpenSSH 10. 26.04 ships OpenSSH 10.2 (24.04 has 9.6). Each connection is handled by a separate sshd-session process, so log lines and ps output say sshd-session where they used to say sshd, and DSA keys are no longer accepted. Ubuntu’s fail2ban package matches on the ssh.service unit, so the jail above sees both.

What this does not cover

  • Docker. Ports published with -p get iptables rules of Docker’s own that UFW never evaluates, so a container on 0.0.0.0:5432 is reachable whatever ufw status says. Publish on 127.0.0.1 instead.
  • Databases. MySQL and PostgreSQL from Ubuntu’s archive listen on localhost only. Keep them there, and if another server needs them, open the port to that server’s address alone: ufw allow from 10.0.0.5 to any port 5432 proto tcp.
  • TLS. Ports 80 and 443 are open; certificates are a separate step with certbot.
  • Backups. A hardened server still loses its disk. See backing up MySQL and PostgreSQL to S3.
  • Compliance baselines. CIS benchmarks and audit logging go much further than this list, and much of what they add is aimed at multi-user machines rather than a single-purpose web server.
  • Your application. A hardened OS does not fix an injectable query or a leaked .env. For the web side of a PHP server, see deploying Laravel to Ubuntu.

How Provisore does this

When you connect a server, Provisore’s provisioning runs this same sequence: a provisore deploy user, swap sized to the machine, UFW for 22, 80 and 443, a fail2ban SSH jail and unattended security upgrades, then Nginx and certbot. It verifies that the deploy user can log in, and only then disables root login and password authentication. Your team’s SSH keys are attached to each server from one pool, described under firewall and SSH access.

Run it on a server you own

Provisore provisions the server, deploys on push and keeps watch, on your own provider account.