Ubuntu Server Hardening Checklist for Production Use

17 min read 3,243 words
Ubuntu Server Hardening Checklist for Production Web Applications featured image

What a default Ubuntu installation leaves exposed

A freshly installed Ubuntu server answers on port 22, accepts passwords, and trusts whatever the installer left running. Automated scanners find it within minutes of the address becoming reachable. None of that is a flaw in Ubuntu; the defaults are chosen so the machine is usable on first boot, and closing them down is your job.

This checklist covers the work that should happen before any application is deployed. It targets Ubuntu 24.04 LTS, with notes where 22.04 LTS behaves differently, and uses only packages from the standard archive. Order matters. SSH first, firewall second, everything else after that, because reversing those two is the classic way to lock yourself out of a machine you cannot walk up to.

Before you start: access and a way back in

You need a non-root account with sudo, a working key, and a recovery route that does not depend on SSH. On a VPS that means the provider's web console or serial console. On hardware it means IPMI, a KVM or somebody in the building. Set aside an uninterrupted half hour, and do this on a staging box first if you have one.

Start by recording what the machine looks like now, so you can tell what your changes did.

sudo sshd -T | sort > /tmp/sshd-before.txt
systemctl list-units --type=service --state=running
sudo ufw status verbose
ss -tulpn

sshd -T prints the effective configuration after every include file has been merged. That distinction matters more than it used to, and the reason is in the next section.

SSH key authentication, and the include file that overrides you

SSH is the main door. The defaults leave password authentication on, which means every credential-stuffing botnet on the internet gets unlimited guesses against your account names.

Generate an ed25519 key on your own machine, never on the server. Ed25519 keys are short, fast to verify, and supported by every current SSH client and server.

ssh-keygen -t ed25519 -C "deploy key for example.com"

Use a passphrase. A private key without one is a plaintext credential sitting in your home directory, and anything that reads your filesystem gets your servers as well. ssh-agent caches it so you type it once per session.

eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
ssh-copy-id -i ~/.ssh/id_ed25519.pub deployuser@203.0.113.50

Log in with the key in a second terminal and confirm it works. Keep that terminal open for the rest of this section. Disabling passwords with no working key is a call to your hosting provider.

Now the part that trips people up on 24.04. The shipped /etc/ssh/sshd_config begins with an include line.

Include /etc/ssh/sshd_config.d/*.conf

OpenSSH takes the first value it sees for any keyword, and the include sits at the top of the file. Anything in that directory therefore wins over the settings further down the main file. Cloud images routinely ship 50-cloud-init.conf containing PasswordAuthentication yes, which is why so many people harden sshd_config, restart the service, and find passwords still accepted.

Write your own drop-in with a name that sorts ahead of anything already there, for example /etc/ssh/sshd_config.d/10-hardening.conf.

PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PermitEmptyPasswords no
MaxAuthTries 3
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no

Note KbdInteractiveAuthentication. The old name was ChallengeResponseAuthentication, which OpenSSH renamed some releases ago and now treats as deprecated. Guides written before that change still use the old keyword, and on a current OpenSSH it is at best a warning in the log.

Validate the syntax, then compare the effective configuration against what you intended.

sudo sshd -t
sudo sshd -T | grep -Ei "passwordauthentication|permitrootlogin|kbdinteractive|maxauthtries"
sudo systemctl restart ssh

Read that sshd -T output rather than trusting the file you edited. It is the only answer that accounts for every include, and it takes five seconds.

MaxAuthTries 3 ends a session after three failures, which stops a single connection cycling through hundreds of guesses. Root login stays off so every privileged action is attributable to a named account through sudo, which is what makes an incident investigation possible at all. Agent and TCP forwarding are both disabled because they are lateral-movement tools you almost certainly do not need on a web server. Further options are covered in the notes on securing SSH on Ubuntu.

Changing the SSH port on a socket-activated system

Moving SSH off port 22 does not stop a determined attacker, and anyone who tells you otherwise is overselling it. What it does is remove the constant background noise of untargeted scanning from your logs, so a genuine attempt is visible instead of buried under thousands of bot entries.

On Ubuntu 24.04 there is a catch. OpenSSH is socket-activated, which means systemd owns the listening socket and the Port directive in sshd_config is ignored. Editing sshd_config, restarting, and finding the server still on 22 is the expected outcome, not a mistake on your part. The port lives in the socket unit instead.

sudo systemctl edit ssh.socket

Add the following, keeping the empty ListenStream= line, which clears the inherited value before setting a new one.

[Socket]
ListenStream=
ListenStream=2222

Open the firewall for the new port before you touch the socket, then reload and check what is actually listening.

sudo ufw allow 2222/tcp comment "SSH"
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
ss -tlnp | grep 2222

Connect on the new port from a second terminal before closing the first. If your machine is not socket-activated, which is the case on some minimal and older images, the traditional Port 2222 directive applies instead. systemctl is-enabled ssh.socket tells you which world you are in.

Fail2Ban to close the repeat offenders

Key-only authentication already defeats password guessing, so Fail2Ban is not what keeps attackers out. What it does is stop the same handful of addresses consuming your connection slots and filling the journal, and it gives you a list of who is trying.

sudo apt update && sudo apt install fail2ban -y

Never edit jail.conf. Package upgrades replace it. Put your settings in /etc/fail2ban/jail.local, which is read afterwards and left alone by apt.

[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
ignoreip = 127.0.0.1/8 ::1

[sshd]
enabled  = true
port     = 2222
backend  = systemd
maxretry = 3

bantime.increment lengthens the ban each time the same address comes back, so persistent scanners disappear for progressively longer without you setting an aggressive first ban. The systemd backend reads the journal directly, which matters because minimal Ubuntu images no longer install rsyslog, and a jail pointed at /var/log/auth.log on such a machine silently monitors a file that does not exist.

sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
sudo fail2ban-client set sshd unbanip 198.51.100.42

Keep that last command somewhere you can find it from a phone. Full jail configuration, including web server protection, is covered in the Fail2Ban setup guide for Ubuntu.

UFW: deny by default, allow on purpose

UFW is a front end to the kernel's netfilter framework, installed on Ubuntu by default and usually inactive. The policy you want is simple: refuse everything inbound, permit everything outbound, then open the specific ports the machine exists to serve.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 2222/tcp comment "SSH"
sudo ufw allow 80/tcp comment "HTTP"
sudo ufw allow 443/tcp comment "HTTPS"
sudo ufw status numbered

Read the numbered list carefully. This is the last moment before the firewall starts enforcing, and a missing SSH rule here is the single most common way people lose access to their own server.

sudo ufw enable
sudo ufw status verbose

Database ports belong on a private interface or behind a source restriction, never open to the world. UFW can scope a rule to one address, which is the right shape for an application server talking to a database host.

sudo ufw allow from 10.0.0.20 to any port 3306 proto tcp comment "app server"
sudo ufw delete 3

Automatic security updates

The gap between a patch being published and being installed is the window in which a known exploit works against you. Automating that removes the dependency on somebody remembering.

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

The work is driven by two systemd timers, apt-daily.timer and apt-daily-upgrade.timer, so systemctl list-timers 'apt-daily*' tells you when the next run is due. Configuration lives in /etc/apt/apt.conf.d/50unattended-upgrades.

Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
    "${distro_id}ESMApps:${distro_codename}-apps-security";
    "${distro_id}ESM:${distro_codename}-infra-security";
};

Unattended-Upgrade::Mail "alerts@example.com";
Unattended-Upgrade::MailReport "on-change";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Automatic-Reboot "false";

Two notes on that block. MailReport replaced the older MailOnlyOnError setting, and mail of any kind needs a working mail transfer agent on the box. Adding -updates to the origins list pulls in bug fixes as well as security patches, which is a defensible choice on a staging server and a riskier one in production.

Leaving Automatic-Reboot off is deliberate. A kernel update needs a restart to take effect, and a server that reboots itself at 06:00 without anyone watching is an incident waiting for a bad night. Check whether a restart is pending and schedule it yourself.

sudo unattended-upgrade --dry-run --debug
ls /var/run/reboot-required 2>/dev/null && echo "reboot pending"

The binary is unattended-upgrade, singular, while the package is unattended-upgrades, plural. That inconsistency has confused more people than it should.

Accounts and privilege

Day-to-day work happens as a named user; privilege comes from sudo, one command at a time, with each escalation logged against a person.

sudo adduser deployuser
sudo usermod -aG sudo deployuser
sudo install -d -m 700 -o deployuser -g deployuser /home/deployuser/.ssh
sudo install -m 600 -o deployuser -g deployuser \
  ~/.ssh/authorized_keys /home/deployuser/.ssh/authorized_keys

Passwordless sudo is convenient and it means any code running as that user has root for free. Keep the password requirement unless a deployment pipeline genuinely needs otherwise, and if it does, scope the exemption to the specific commands rather than to everything.

echo 'deployuser ALL=(ALL) PASSWD: ALL' | sudo tee /etc/sudoers.d/deployuser
sudo chmod 440 /etc/sudoers.d/deployuser
sudo visudo -c

That last command parses the whole sudoers tree. Run it every time, because a syntax error in a sudoers file can leave nobody on the machine able to escalate.

Review the account list periodically, and lock rather than delete anything you are unsure about, since deleting a user whose files are still referenced causes its own problems.

awk -F: '$3 >= 1000 && $1 != "nobody" {print $1, $7}' /etc/passwd
sudo passwd -l olduser

Turning off services the machine does not need

Every listening service is another thing that can have a bug. On a web server, quite a few of the defaults have no reason to be there.

  • cups prints. A headless server does not print.
  • avahi-daemon advertises services over multicast DNS, which is useful on a laptop and noise on a server with a static address.
  • rpcbind maps RPC services, is rarely needed outside NFS, and turns up constantly in scan traffic.
  • bluetooth exists on many cloud images for no reason at all.

Check what is actually listening before switching anything off, then disable and mask the ones you have no use for.

ss -tulpn
sudo systemctl disable --now cups avahi-daemon rpcbind bluetooth
sudo systemctl mask cups avahi-daemon

Masking goes further than disabling: a masked unit cannot be started even as a dependency of something else, which stops a later package install quietly bringing it back.

Kernel network settings with sysctl

Kernel defaults favour compatibility. On an internet-facing machine a handful of them are worth tightening. Put the changes in their own file so an Ubuntu upgrade does not overwrite them.

The first group rejects spoofed and redirected traffic, and turns on SYN cookies so the machine keeps answering legitimate connections during a SYN flood instead of exhausting its connection table.

net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.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.ipv6.conf.all.accept_source_route = 0
net.ipv4.tcp_syncookies = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.conf.all.log_martians = 1

The second group limits what an unprivileged process can learn about the kernel and about other processes. kptr_restrict hides kernel pointers from /proc, dmesg_restrict keeps the kernel ring buffer away from ordinary users, and ptrace_scope stops one process attaching a debugger to another that is not its child.

kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 1
fs.suid_dumpable = 0
fs.protected_hardlinks = 1
fs.protected_symlinks = 1

The correct key is kernel.yama.ptrace_scope. A number of older hardening guides print it as kernel.yama.scope, which does not exist, so the line is rejected and the protection was never applied. Save both blocks into /etc/sysctl.d/99-hardening.conf, apply them, and check that each one took.

sudo sysctl --system
sysctl kernel.yama.ptrace_scope net.ipv4.tcp_syncookies net.ipv4.conf.all.rp_filter

Ubuntu 24.04 also restricts unprivileged user namespaces through AppArmor by default, which closes off a family of local privilege escalation techniques. Some container and sandbox tooling needs that relaxed. If something breaks after upgrading from 22.04, that setting is worth checking before you start blaming the application. There is more on release-specific behaviour in the notes on Ubuntu security hardening.

Audit logging with auditd

System logs tell you what services did. The audit subsystem tells you what people did, which is the record you want when something has gone wrong and nobody is volunteering an explanation.

sudo apt install auditd -y
sudo systemctl enable --now auditd

Rules added with auditctl are lost at reboot, so put permanent rules in a file under /etc/audit/rules.d/ and load them with augenrules.

-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers
-w /etc/ssh/sshd_config -p wa -k sshd_config
sudo augenrules --load
sudo auditctl -l
sudo ausearch -k identity --start today
sudo aureport --auth --summary

Audit logs grow. Set max_log_file and num_logs in /etc/audit/auditd.conf to bound the disk usage, and decide deliberately what happens when the limit is reached.

File permissions that matter

Most permission problems on a web server come down to the application being able to rewrite its own code. If the PHP process can write to the directory it executes from, a file upload flaw becomes remote code execution.

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys ~/.ssh/id_ed25519
sudo find /var/www/example -type d -exec chmod 755 {} +
sudo find /var/www/example -type f -exec chmod 644 {} +
sudo find /var/www -type f -perm /o=w -printf "%m %p\n"

Directories need the execute bit to be traversable at all, so 755 on a directory is correct and 755 on every file is not. The final command lists world-writable files, which should print nothing.

Checking your work with Lynis

Lynis audits the running system and produces a list of findings with suggestions. Run it once when the machine is built to record a baseline, then again after changes to see what moved.

sudo apt install lynis -y
sudo lynis audit system --quick | tee /var/log/lynis-$(date +%Y%m%d).log

Treat the output as a prompt for thought rather than a score to maximise. Lynis does not know which of its suggestions apply to your workload, and chasing every one of them on a machine with a specific job wastes time you could spend on backups.

Mistakes that undo the work

Enabling UFW before allowing the SSH port is the classic one, and it happens to experienced people in a hurry. Adding the rule takes ten seconds and the recovery takes an hour.

Editing sshd_config without checking sshd -T is the modern equivalent, because an include file can quietly undo everything you just wrote.

Setting a Fail2Ban ban that is too aggressive locks out the person who mistypes a passphrase, usually at the worst moment. Keep your own address in ignoreip while you are testing.

Applying all of this to production without a staging pass is how a sysctl change breaks an application's networking on a Friday afternoon. And leaving it undocumented means the next person, quite possibly you, spends an hour working out why SSH is not on 22.

Keeping the machine in this state

Hardening decays. Packages get installed, ports get opened for a one-off test, an agency is given a login and nobody removes it. The habits that hold the line are a scheduled review, notes kept somewhere other than the server, and enough automation that rebuilding the box produces the same configuration rather than a slightly different one.

Once more than two or three servers are involved, write the steps as a script or an Ansible playbook. Consistency is the point: a fleet where every machine was hardened by hand is a fleet where no two are quite the same. The follow-up notes on what to do immediately after an Ubuntu install cover the first-boot end of the same work.

If you would rather have this reviewed than run it yourself, N. Cristea can audit an existing Ubuntu server, report what is actually exposed, and apply the changes with a tested way back in. Get in touch with the distribution version and what the server runs.

Frequently Asked Questions

Do I need UFW if my cloud provider already has security groups?
Yes. Security groups filter at the network edge and give no protection against traffic from another machine inside the same subnet, or against a rule someone widens by accident in the provider console. A host firewall is a second, independent control that stays with the machine if it is ever moved or cloned.
Why does my SSH port change not take effect on Ubuntu 24.04?
Because OpenSSH is socket-activated by default on that release, so systemd holds the listening socket and the Port line in sshd_config is ignored. Change it with systemctl edit ssh.socket, setting an empty ListenStream= followed by the port you want, then restart ssh.socket.
Is this checklist enough for PCI DSS or an ISO 27001 audit?
No. This is a baseline for a general purpose web server. Compliance regimes add requirements around network segmentation, log retention, formal change control, intrusion detection and periodic testing, and they care as much about the evidence you keep as about the configuration itself. Use this as a starting point and read the actual control set you are being measured against.
Can I apply this to a server that is already in production?
Carefully, and with console access open before you start. Do SSH first and verify with a second session at every step. Firewall changes and sysctl changes are the two that most often break an application, so make them one at a time and watch the logs in between rather than applying everything and hoping.
What should I actually look at each week?
Failed and successful authentications, the Fail2Ban ban list, disk usage, and whether unattended upgrades have been applying cleanly. A pending reboot flag sitting there for six weeks means the kernel fixes you automated are not in use yet.