What a Default Ubuntu Installation Actually Leaves Exposed

A newly installed Ubuntu server has SSH on port 22 with password authentication enabled, every common port exposed to the internet, and no automatic security updates configured. Any server connected to the internet will be scanned and attacked within minutes of going live. Automated bots probe for weak SSH credentials, exploit known vulnerabilities in default packages, and attempt to relay spam through misconfigured mail servers. The default configuration tolerates all of this.

This checklist covers the steps that prevent the common attack vectors before any web application touches the network. It applies to Ubuntu 22.04 LTS and 24.04 LTS on bare metal, VPS, or cloud instances. No third-party tools are required — everything uses packages from the standard Ubuntu repositories. The order matters: SSH hardening first, then firewall, then everything else. Skipping the SSH hardening steps risks locking yourself out when the firewall is enabled.

Before You Start: Access and Prerequisites

You need a non-root user account with sudo access before touching any hardening. If you are already logged in as root, create a deploy user first. You also need console access or IPMI access to the server in case you lock yourself out during configuration. Finally, set aside at least 30 minutes of uninterrupted time. Make changes in a staging environment first if you have one.

The assumption throughout this guide is that you are starting with a default Ubuntu installation with no existing hardening applied. If you are working on a server that already has some configuration, audit the current state before applying the steps below.

# Check current SSH configuration
sshd -t

# Check which services are running
systemctl list-units --type=service --state=running

# Check UFW status
sudo ufw status

SSH Key-Only Authentication: The Non-Negotiable First Step

SSH is the primary attack surface on any internet-exposed server. Default settings are too permissive: password authentication is enabled, root login is allowed, and there is no rate limiting on login attempts. Every one of these is a known exploitation path. Automated brute-force SSH attacks are constant, automated, and reliably successful against weak credentials.

Password authentication is a liability because it can be brute-forced. A six-character alphanumeric password has roughly two billion possible combinations — trivial for a modern GPU to test in hours. SSH key authentication uses asymmetric cryptography where the private key never leaves your machine and cannot be guessed remotely. Disabling password authentication removes the entire class of remote password-guessing attacks from your threat model entirely.

Generate an ed25519 key pair on your workstation. Ed25519 keys are smaller than RSA keys (256 bits vs 2048 bits), faster to authenticate, and considered more secure for most use cases. The NSA's 2022 CNSA suite specifies Curve25519 for key exchange and Ed25519 for digital signatures, which means ed25519 meets government security standards. RSA is acceptable if your tooling requires it, but ed25519 is preferred for new deployments.

# Generate on your local workstation (not the server)
ssh-keygen -t ed25519 -C "deploy-key-$(date +%Y)"

When prompted for a passphrase, use one. The key itself is worthless to an attacker without the passphrase, and the passphrase encrypts the private key at rest. A key without a passphrase is readable by anyone who gains access to your workstation filesystem. Use a passphrase and store it in a password manager. The ssh-agent can cache the passphrase so you do not need to type it every time.

# Start the SSH agent
eval "$(ssh-agent -s)"

# Add your key to the agent (prompts for passphrase once)
ssh-add ~/.ssh/id_ed25519

Copy the public key to the server. The ssh-copy-id command handles creating the ~/.ssh directory and setting permissions correctly. This works only if password authentication is still enabled on the server.

ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]

Test the key login before you disable password authentication. Open a second terminal and attempt to log in with the key. If it works, proceed. If it fails, troubleshoot before continuing — disabling password auth with no working key is a lockout scenario that requires console access to resolve.

ssh -i ~/.ssh/id_ed25519 [email protected]

# Confirm this succeeds before proceeding

Edit the SSH daemon configuration at /etc/ssh/sshd_config. Create a backup first so you can restore if something goes wrong.

sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup
sudo nano /etc/ssh/sshd_config

Set the following parameters. Find each line in the file, uncomment it if it is already present, or add it if it is missing.

PubkeyAuthentication yes
PasswordAuthentication no
PermitRootLogin no
ChallengeResponseAuthentication no
UsePAM yes
X11Forwarding no
MaxAuthTries 3
AllowAgentForwarding no
AllowTcpForwarding no
GatewayPorts no
PermitEmptyPasswords no

MaxAuthTries 3 means that three failed authentication attempts disconnect the session. This prevents the brute-force approach of running through thousands of password combinations in one session. Setting it to 3 allows for a mistyped passphrase without locking you out, while stopping automated tools that try hundreds of combinations. The other settings disable features that are rarely needed on a server and represent unnecessary attack surface — agent forwarding and TCP forwarding in particular have been used in post-compromise lateral movement.

Apply the changes by restarting the SSH daemon. The restart drops existing connections so new configuration takes effect — your current session is not affected because you already authenticated before the restart.

sudo systemctl restart sshd

Root login is disabled because it bypasses sudo logging and removes the audit trail of who ran what command. Every privileged operation should go through a named user account with sudo. This matters for incident response: when something goes wrong, you need to know which human ran which command, and that is only possible if every command runs under a named account. With root login disabled, a compromised credential gives the attacker user-level access, not root — the difference between a server that needs rebuilding and a server that is immediately fully compromised.

For a deeper look at SSH hardening techniques including key management and additional configuration options, see the guide to securing SSH on Ubuntu.

Changing the SSH Port: Reducing Automated Noise Without Illusion

Port 22 is scanned automatically within minutes of any server connecting to the internet. Automated bots run by spammers and credential stuffers scan the entire internet on port 22, trying common usernames and passwords. Moving SSH to a non-standard port reduces this noise so that genuine authentication events are visible in logs rather than buried under automated noise.

This is not security through obscurity. Security through obscurity is relying on port-knocking or hidden URLs as the primary defence — that is weak. Moving the SSH port is simply reducing noise so that you can actually see targeted attacks in your logs. A port scan of port 22 across the entire IPv4 address space takes less than an hour with modern tools. Sophisticated attackers scan all ports. Casual bots scan port 22. Moving to a high port stops the casual bots and reduces log volume. It is a minor but worthwhile hardening measure.

Choose a port between 1024 and 65535 that is not already in use. Ports below 1024 require root to bind; higher ports can be bound by any user, but the SSH daemon runs as root so this is not a constraint. High-numbered ports are less likely to be scanned in casual sweeps. Do not use a port that another service on your server already uses.

# Check if a port is already in use
sudo ss -tlnp | grep :2222

# If nothing is listening on it, you can use it

Edit /etc/ssh/sshd_config and change the Port line. After saving, update your firewall to allow the new port before restarting SSH. If you restart SSH before the firewall rule is added, you will lock yourself out.

# Add the new SSH port to UFW before restarting SSH
sudo ufw allow 2222/tcp comment "SSH on non-standard port"

# Then restart SSH
sudo systemctl restart sshd

Test the new port before closing your current session. Open a second terminal and connect to the new port. Keep the old session open until you confirm the new connection works, then close the old session and verify you can reconnect on the new port.

ssh -p 2222 -i ~/.ssh/id_ed25519 [email protected]

Fail2Ban: Stopping Brute-Force Attacks Automatically

Brute-force attacks against SSH are constant on any public server. They run continuously from multiple sources, trying common username and password combinations. A single compromised router or IoT device can generate thousands of login attempts per day. Without Fail2Ban, your logs are full of authentication failures from bots all over the world. With it, repeat offenders are blocked automatically and you can review the ban list to understand who is attacking you.

Fail2Ban monitors authentication logs and automatically bans IP addresses that fail too many login attempts. The ban is implemented via iptables, which blocks all connections from the banned IP for the configured duration. This makes brute-force attacks ineffective because the attacker cannot complete enough attempts to find a valid credential before being banned.

sudo apt update && sudo apt install fail2ban -y
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

The default configuration in /etc/fail2ban/jail.conf is reasonable, but package updates to the default configuration will overwrite your changes. Override specific settings in a custom jail file so that package updates do not affect your settings. Create /etc/fail2ban/jail.local for your overrides.

sudo nano /etc/fail2ban/jail.local

Add SSH jail configuration that matches your non-standard port and your tolerance for failed attempts. Setting maxretry to 3 means after three failed attempts from the same IP within findtime (5 minutes), that IP is banned for bantime (600 seconds = 10 minutes by default). Adjust these based on how many times you typically mistype your passphrase.

[sshd]
enabled = true
port = 2222
maxretry = 3
bantime = 600
findtime = 300
action = iptables-allports
logprotocol = auto
backend = auto

Reload Fail2Ban to apply the new configuration:

sudo fail2ban-client reload

# Check current status
sudo fail2ban-client status sshd

The status output shows how many IPs are currently banned and the list of banned IPs. Check this before assuming your configuration is not working — a ban that has expired already will not appear in the status. The bans are temporary by default; when bantime expires, the IP is unblocked automatically.

# Unban a specific IP if you accidentally locked yourself out
sudo fail2ban-client set sshd unbanip 198.51.100.42

# Check recent bans in the journal
sudo journalctl -u fail2ban -n 50

For a complete walkthrough of Fail2Ban setup including HTTP protection, see the Fail2Ban SSH and HTTP protection guide.

UFW Firewall: Deny Everything Except What You Explicitly Allow

Uncomplicated Firewall (UFW) is a front-end to netfilter, the Linux kernel packet filtering framework. It is installed by default on Ubuntu but is usually disabled. The default configuration is to deny all incoming connections and allow all outgoing. This is the correct starting point. You should explicitly allow only the ports your server needs to function.

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing

Add the rules for SSH on your non-standard port, plus the web server ports. If you skip the SSH rule before enabling the firewall, you will lock yourself out. This is the most common hardening mistake — people enable UFW and immediately lose SSH access because they forgot to allow the port they were using.

sudo ufw allow 2222/tcp comment "SSH"
sudo ufw allow 80/tcp comment "HTTP"
sudo ufw allow 443/tcp comment "HTTPS"

If you run a database server that needs external access, add those rules. For most web servers, the only ports that need to be open are SSH, HTTP, and HTTPS. Everything else should be denied by default.

sudo ufw status numbered

# Review the list carefully before proceeding

Enable the firewall. You will see a warning that the command may disrupt existing SSH connections. If your SSH rule is correct, proceed.

sudo ufw enable

# Confirm with 'y' at the prompt

Verify the status shows the firewall as active:

sudo ufw status verbose

If you lock yourself out, cloud providers allow access via their web console or serial console. Physical servers require IPMI or KVM access. Test your firewall rules in staging before applying to production.

Delete a rule if you added the wrong one:

sudo ufw delete 3  # delete rule number 3 from the numbered list

Automatic Security Updates: Closing the Patch Window

Unpatched software is the most common breach vector on Linux servers. The Log4Shell vulnerability (CVE-2021-44228) was discovered in December 2021 and had active exploitation within days. Servers that had automatic updates configured were patched within hours of the patch being released. Servers that required manual patching remained vulnerable for weeks or months. The window between a security patch being released and it being applied is the window during which known exploits can target your server.

Ubuntu includes unattended-upgrades for this purpose. It checks for security updates daily, downloads them, and applies them automatically. You configure what is upgraded and how notifications are sent.

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

The dpkg-reconfigure step launches an interactive prompt. Select yes to enable automatic upgrades. After enabling, configure what is upgraded automatically by editing the configuration.

sudo nano /etc/apt/apt.conf.d/50unattended-upgrades

Extend the default configuration to include -updates as well as security updates, and configure notifications so you know when updates run. The Mail parameter sets where notifications are sent. Setting MailOnlyOnError to false means you receive an email every time unattended-upgrades runs, not just when something goes wrong.

Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
    "${distro_id}:${distro_codename}-updates";
};

Unattended-Upgrade::Automatic-Upgrade "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Mail "[email protected]";
Unattended-Upgrade::MailOnlyOnError "false";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::SyslogEnable "true";

Setting Automatic-Reboot to false is intentional for most web servers. A server that reboots unexpectedly during business hours causes an incident. Set this to true only if you have monitoring in place to handle the restart and the business tolerates automatic reboots. For most production servers, a manual reboot during a maintenance window is preferable to an automatic one.

Enable the service and verify it runs without errors:

sudo systemctl enable unattended-upgrades
sudo unattended-upgrades --debug --dry-run

The --dry-run flag runs the process without applying updates. The --debug flag outputs verbose information about what would happen. Review the output to confirm the configuration is correct.

User and Privilege Management

Operating as root via SSH is unnecessary risk. Every command run as root runs with full system privileges and bypasses sudo logging, which removes the audit trail. Use a standard account for day-to-day administration and sudo for privilege escalation. This means every privileged command is logged with the user who ran it.

Create a non-root sudo user:

sudo adduser deployuser
sudo usermod -aG sudo deployuser

# Add your SSH key to the new user
sudo mkdir -p /home/deployuser/.ssh
sudo cp ~/.ssh/authorized_keys /home/deployuser/.ssh/authorized_keys
sudo chown -R deployuser:deployuser /home/deployuser/.ssh
sudo chmod 700 /home/deployuser/.ssh
sudo chmod 600 /home/deployuser/.ssh/authorized_keys

Configure sudo to require a password for privilege escalation. This prevents automated scripts from running privileged commands without an explicit action from a human. Edit /etc/sudoers.d/deployuser:

sudo nano /etc/sudoers.d/deployuser

Add the line that requires a password for sudo access by this user:

deployuser ALL=(ALL) PASSWD: ALL

Set correct permissions on the sudoers drop-in file so that sudoers can read and parse it correctly:

sudo chmod 440 /etc/sudoers.d/deployuser

Audit existing accounts periodically to identify any that should not exist. List all non-system accounts and review whether each is needed:

getent passwd | grep -v "/usr/sbin/nologin" | grep -v "/bin/false"

# Disable an account that should not exist
sudo passwd -l username_to_disable

The -l flag locks the account by prepending !! to the password hash, making the password unusable. The account still exists but cannot be logged into. This is reversible with sudo passwd -u username.

Disable Unused Services: Reducing Attack Surface

A default Ubuntu installation starts services you do not need. Each running service is an additional attack surface: it listens on a network port, it may have vulnerabilities, and it may have misconfigurations that can be exploited. Disable anything that is not required for your server's specific role.

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

Review the list. Common services to disable on a dedicated web server include:

  • bluetooth.service: The Bluetooth daemon runs on servers and is never needed for headless deployments. It listens on a local interface and adds kernel attack surface.
  • cups.service: Common Unix Printing System, a print server daemon not needed unless you run a print server.
  • avahi-daemon.service: Zeroconf multicast DNS daemon that creates network noise and potential attack surface. On a server with a static IP and known hostname, it is not needed.
  • rpcbind.service: RPC portmapper, rarely needed on a web server and frequently targeted in scans. The RPC mechanism was largely replaced by HTTP APIs and is considered a security liability on modern servers.
sudo systemctl disable --now bluetooth
sudo systemctl disable --now cups
sudo systemctl disable --now avahi-daemon
sudo systemctl disable --now rpcbind

After disabling services, confirm they are not running:

systemctl status bluetooth
systemctl status cups

# Both should show "inactive (dead)"

Kernel Network Hardening with sysctl

Linux kernel parameters control how the network stack handles various types of traffic. The default values are designed for general compatibility, not for security on an internet-facing server. These settings have been validated in production environments across thousands of deployments.

sudo nano /etc/sysctl.d/99-hardening.conf

Add the following settings. Each serves a specific hardening purpose that has been documented in real exploitation scenarios.

# Enable source address validation to prevent IP spoofing
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# Disable ICMP redirect acceptance (man-in-the-middle attack vector)
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0

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

# Enable SYN cookies to prevent SYN flood DoS attacks
net.ipv4.tcp_syncookies = 1

# Restrict ICMP broadcasting to prevent smurf amplification attacks
net.ipv4.icmp_echo_ignore_broadcasts = 1

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

# Disable core dumps for security-sensitive processes
kernel.core_pattern = |/bin/false
fs.suid_dumpable = 0

# Disable kernel debugging interfaces
kernel.kptr_restrict = 2
kernel.yama.scope = 1

Apply the settings without rebooting:

sudo sysctl --system

Verify the settings loaded correctly:

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

# Both should return 1

SYN cookies allow the server to continue accepting connections during a SYN flood attack by encoding connection state in the SYN-ACK response itself rather than maintaining state on the server. A SYN flood attack that exhausts the connection table on a server without SYN cookies makes the server unresponsive to legitimate traffic. With SYN cookies enabled, the server remains responsive even under attack.

The guide to Ubuntu 22.04 security hardening covers these kernel parameters in more detail along with additional network protections.

Installing and Configuring Auditd

The Linux Audit subsystem records security-relevant events including login attempts, privilege escalation via sudo, and changes to critical system files. This is essential for incident response. When a server is compromised, the audit logs tell you what happened, when it happened, and in some cases who did it. Without auditd, you have system logs but not the detailed security event record that incident response requires.

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

Configure watches on critical system files. Each -w directive watches the specified path, -p wa specifies write and attribute change monitoring, and -k assigns a label for searching in the audit logs.

sudo auditctl -w /etc/passwd -p wa -k identity_changes
sudo auditctl -w /etc/shadow -p wa -k identity_changes
sudo auditctl -w /etc/sudoers -p wa -k sudoers_changes
sudo auditctl -w /var/log/ -p wa -k login_activity
sudo auditctl -l

Query the audit log to find events by key name. The aureport command produces a summary of authentication events. The ausearch command finds specific events by key.

sudo aureport --auth --summary
sudo ausearch -k identity_changes
sudo ausearch -k sudoers_changes

Audit events are written to /var/log/audit/audit.log. This file can grow large on busy servers. Configure log rotation for it by adding to /etc/logrotate.d/auditd or by configuring the audit daemon's max_log_file setting in /etc/audit/auditd.conf.

File and Directory Permissions

Enforce least privilege on files and directories that matter most. Incorrect permissions are a common cause of server compromise, particularly for web roots and SSH configuration.

# SSH key directory must be readable only by the owner
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

# Private key must not be group or world readable
chmod 600 ~/.ssh/id_ed25519

# Sudoers directory locked
chmod 755 /etc/sudoers.d

# Web root: adjust path to match your setup
sudo chown -R root:root /var/www/html
sudo chmod -R 755 /var/www/html

Upload directories should never have execute permission. If a PHP file is uploaded to /var/www/html/uploads and the web server can execute it, that upload is a remote code execution vulnerability. Remove execute permissions from upload directories and ensure uploaded files cannot be executed:

# Remove execute from upload directories
find /var/www/html/uploads -type d -exec chmod 755 {} \;
find /var/www/html/uploads -type f -exec chmod 644 {} \;

Check the permissions on SSH private keys. The private key (id_ed25519 or id_rsa) should be 600 — readable only by the owner. If it shows 644 or 777, that is a security problem: any user on the system can read the private key and use it to access your server.

ls -la ~/.ssh/

# The private key should be 600
# The public key can be 644
# The directory should be 700

Installing and Running Lynis for Security Audit

Lynis is an open-source security auditing tool for Unix systems. It runs a comprehensive suite of security tests and produces a hardening report with specific remediation suggestions. Run it on a new server to establish a baseline, then periodically to track hardening progress.

sudo apt install lynis -y
sudo lynis audit system --quick

The --quick flag runs the audit without pausing for interactive prompts. Review the output with attention to warnings and suggestions. Items flagged as medium or high severity are the ones that require attention. The audit results vary based on what services are installed — a web server will have different findings than a database server.

Save the output to a file for comparison when you run the next audit:

sudo lynis audit system --quick > /var/log/lynis-audit-$(date +%Y%m%d).log

Logwatch for Daily System Monitoring

Logwatch summarises system logs and sends a daily email report. Instead of reading raw log files every day, you receive a digest of the most important events. This makes daily review practical without requiring you to grep through log files manually.

sudo apt install logwatch -y
sudo cp /usr/share/logwatch/default.conf/logwatch.conf /etc/logwatch/conf/logwatch.conf

Edit /etc/logwatch/conf/logwatch.conf to set the email address and detail level:

mailto = "[email protected]"
Detail = High
mailer = /usr/sbin/sendmail -t

Logwatch can be run manually or via cron. Add it to the daily cron to receive reports automatically:

# /etc/cron.daily/00logwatch
/usr/sbin/logwatch --output mail --mailto [email protected] --detail high

Common Mistakes That Undermine the Hardening

Skipping the test environment is the most consequential mistake. UFW rules that look correct can block required application traffic. sysctl changes can affect kernel networking in ways that break application connectivity. sudoers changes can break deployment scripts that expect passwordless sudo. Test in staging before applying to production.

Setting overly aggressive Fail2Ban bans without testing is a common lockout cause. A maxretry of 1 on a jail that bans for 24 hours locks out legitimate users who mistype their passphrase once. Test any Fail2Ban configuration from an external IP before deploying it across all servers, and ensure you have a way to unban yourself if you get locked out.

Enabling UFW without adding the SSH rule first is the most common lockout cause. Always add the SSH allow rule before enabling the firewall, and test the SSH connection after enabling before closing the session.

Not documenting the changes is a mistake that creates problems later. After applying hardening, document the SSH port, the firewall rules, the unattended-upgrades configuration, and any sysctl changes. This documentation is what you use the next time you set up a server, and what you refer to during incident response. Keep it in a password manager or secure note, not on the server itself.

Skipping Fail2Ban because the port changed is a mistake. The SSH port change reduces automated scanning noise but does not stop targeted attacks. A determined attacker scans all ports. Fail2Ban catches the repeat attempts that port change alone does not address. Key-only auth plus Fail2Ban plus non-standard port is the complete SSH hardening approach.

What to Do After Completing the Checklist

Document everything. Record the non-standard SSH port, the firewall rules in place, the unattended-upgrades configuration, and any sysctl changes made.

For managing multiple servers, automate the hardening process. Running the same commands manually across three or more servers is error-prone and the configuration drifts. An Ansible playbook or a custom bash script run at first boot applies the same hardening consistently every time.

Set recurring review tasks. Run Lynis quarterly to track hardening progress. Review Fail2Ban logs monthly for unusual patterns. Review unattended-upgrades log after major updates to confirm they applied cleanly. Hardening is not a one-time task — it is an ongoing operational practice.

If you are running web applications on this server, a regular WordPress security audit can identify vulnerabilities in your application layer that server hardening alone does not address.