Ubuntu 24.04 is reasonably secure as a fresh installation, but production hardening is about reducing attack surface and making access, updates, network exposure, application confinement, logging, and recovery deliberate. A useful baseline does not come from copying a long list of random sysctl values or disabling every feature. It comes from verifying which users, services, ports, packages, and recovery paths your server actually needs.
Raff Technologies is the VM platform used by the original tutorial. The saved tested-on record remains Ubuntu 24.04 LTS on Raff 2 vCPU / 2 GB RAM Linux VM; OpenSSH, UFW, unattended-upgrades, Fail2Ban, and recovery guidance reviewed in July 2026. This revision re-verifies the workflow against current Ubuntu Server documentation on September 5, 2026 without claiming a new end-to-end machine test.
This tutorial hardens a general-purpose internet-connected Ubuntu server with a non-root administrator, tested SSH keys, safe OpenSSH drop-ins, a deny-by-default UFW policy, verified automatic security updates, service/account review, AppArmor, optional Fail2Ban, logging, and a tested recovery plan. It intentionally avoids hard-coded SSH cipher lists, cargo-cult kernel tweaks, and claims of CIS/FIPS/compliance. Those controls require workload-specific testing and, where applicable, the appropriate compliance tooling.
Prerequisites:
- An Ubuntu 24.04 server with working administrative access
- A non-root sudo-capable account, or permission to create one
- A second terminal for testing new SSH sessions
- A provider console/rescue path or another recovery method
- Knowledge of the applications and ports the server must keep
Step 1 — Inventory the server before changing security controls
Start by recording the server state you are about to change.
Confirm Ubuntu:
cat /etc/os-release
uname -r
Check the current user and sudo capability:
List listening sockets:
List failed and enabled services:
systemctl --failed
systemctl list-unit-files --type=service --state=enabled
Check the current SSH port and effective authentication settings:
sudo sshd -T | grep -E \
'^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication) '
Check UFW and AppArmor:
sudo ufw status verbose
sudo aa-status
Do not change a control until you know which production dependency it could affect. For example, an application may need a public listener, a backup agent may require outbound access, and a deployment system may rely on a specific SSH account.
Verify: You should have a clear list of current administrators, listening ports, enabled services, SSH settings, firewall state, and whether AppArmor is loaded.
Step 2 — Apply pending Ubuntu updates and plan required reboots
Refresh package metadata and install the updates your maintenance policy allows:
sudo apt update
sudo apt upgrade
Review packages before accepting the upgrade on a production server instead of automatically using -y when you need change control.
Check whether a reboot is required:
if [ -f /var/run/reboot-required ]; then
cat /var/run/reboot-required
else
echo "No reboot required"
fi
If a reboot is required, schedule it when the workload can tolerate it and confirm the server returns healthy afterward.
Review configured repositories:
find /etc/apt/sources.list.d -maxdepth 1 -type f -print
Minimize third-party repositories. Ubuntu's current security guidance explicitly recommends avoiding unnecessary third-party package sources because every additional repository adds a software supply-chain and maintenance dependency.
Do not remove packages just because they look unfamiliar. Package dependencies, cloud agents, networking, boot, and storage components can be critical even when they are not obvious application dependencies.
Verify: apt update should complete successfully, required upgrades should be understood/applied according to policy, and you should know whether a reboot is pending.
Step 3 — Use a dedicated non-root administrator and verify sudo access
If your image already provides a non-root sudo account, keep it and verify it instead of creating another unnecessary administrator:
The second command should return:
If you are currently administering the machine only as root, create a replacement account. Replace deploy with your chosen username:
sudo adduser deploy
sudo usermod -aG sudo deploy
Verify membership:
Then test the new account in a separate session before changing root SSH access:
ssh deploy@your_server_ip
sudo whoami
Use separate administrator identities where practical. Sharing one Linux account or one private key between several people makes revocation and log attribution harder.
Review current sudo members:
Verify: At least one non-root administrator should have a tested SSH path and working sudo, and every sudo member should have a current operational reason for that access.
Step 4 — Install and test SSH key authentication before disabling passwords
Generate an Ed25519 key on the administrator's local computer if needed:
ssh-keygen -t ed25519 -C "ubuntu-admin"
Copy only the public key to the server:
ssh-copy-id your_user@your_server_ip
On the server, review key permissions:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chown -R "$USER":"$USER" ~/.ssh
Open a new terminal and verify key authentication:
ssh your_user@your_server_ip
Do not disable password authentication, root login, or the old working access method until this separate key-based session succeeds.
For key rotation, multiple administrators, and client configuration, use Generate SSH Keys on Ubuntu 24.04.
Verify: A new independent SSH session should authenticate with the intended public key and the account should still have working sudo access.
Step 5 — Harden OpenSSH with an early drop-in and verify the effective configuration
Ubuntu places this line near the top of /etc/ssh/sshd_config:
Include /etc/ssh/sshd_config.d/*.conf
OpenSSH uses the first obtained value for most directives. That means a late file name such as 99-hardening.conf does not necessarily override an earlier snippet. Inspect existing snippets first:
sudo ls -1 /etc/ssh/sshd_config.d/
sudo grep -RniE \
'^(PermitRootLogin|PasswordAuthentication|KbdInteractiveAuthentication|PubkeyAuthentication|MaxAuthTries|LoginGraceTime)' \
/etc/ssh/sshd_config /etc/ssh/sshd_config.d 2>/dev/null || true
Create an early local hardening snippet:
sudo tee /etc/ssh/sshd_config.d/00-raff-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
LoginGraceTime 30
EOF
Protect it with normal root-owned configuration permissions:
sudo chown root:root /etc/ssh/sshd_config.d/00-raff-hardening.conf
sudo chmod 644 /etc/ssh/sshd_config.d/00-raff-hardening.conf
Validate syntax:
Then verify effective values, which is more important than merely reading the file:
sudo sshd -T | grep -E \
'^(permitrootlogin|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|maxauthtries|logingracetime) '
If your configuration uses Match blocks, evaluate the intended user/context as well:
sudo sshd -T -C user="$USER",host="$(hostname)",addr=127.0.0.1 | \
grep -E '^(permitrootlogin|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|maxauthtries|logingracetime) '
Restart SSH only after validation:
sudo systemctl restart ssh.service
Keep the original session open. Test a new key-based login again, then confirm a password-only attempt is rejected:
ssh -o PreferredAuthentications=password \
-o PubkeyAuthentication=no \
your_user@your_server_ip
Do not hard-code custom Ciphers, MACs, or KexAlgorithms in a general baseline unless a defined security/compliance requirement calls for it. Ubuntu's OpenSSH defaults evolve with security updates; custom crypto lists need compatibility testing and long-term maintenance.
Verify: sshd -t should pass, sshd -T should show the intended settings, a fresh key login should work, and password-only/root SSH access should not provide an unintended login path.
Determine the effective SSH port:
sudo sshd -T | awk '$1 == "port" {print $2}'
If SSH uses the standard OpenSSH profile, inspect and allow it before enabling UFW:
sudo ufw app info OpenSSH
sudo ufw allow OpenSSH comment 'SSH administration'
For a custom SSH port, allow the actual port instead:
sudo ufw allow 2222/tcp comment 'SSH administration'
Set a common application-server baseline:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw default deny routed
Review before enabling:
sudo ufw show added
sudo ufw --dry-run enable
Then enable and inspect:
sudo ufw enable
sudo ufw status verbose
Open another new SSH connection before closing existing sessions.
Only add application ports when a real service requires them. For example, a public web server may need TCP 80/443; a private database normally should not be opened to Anywhere.
For source restrictions, IPv6, rule ordering, Docker caveats, and rollback, use Set Up UFW Firewall on Ubuntu 24.04.
Verify: UFW should be active with deny-incoming/deny-routed defaults, the correct SSH rule should exist, and a second SSH connection should still succeed.
Step 7 — Verify automatic security updates instead of overwriting Ubuntu defaults blindly
Ubuntu Server uses unattended-upgrades for automatic security updates and current Ubuntu documentation states that security updates are applied automatically by default.
Check whether the package is installed:
dpkg -s unattended-upgrades 2>/dev/null | grep '^Status:' || true
Install it only if it is absent:
sudo apt install -y unattended-upgrades
Inspect the periodic settings:
apt-config dump | grep -E \
'APT::Periodic::(Update-Package-Lists|Unattended-Upgrade)'
Inspect the timers that drive APT maintenance:
systemctl list-timers apt-daily.timer apt-daily-upgrade.timer --all
Review the actual unattended-upgrades policy:
sudo less /etc/apt/apt.conf.d/50unattended-upgrades
Test it without installing packages:
sudo unattended-upgrade --dry-run --debug
Review recent service activity:
sudo journalctl -u unattended-upgrades --since '7 days ago' --no-pager
Do not assume third-party repositories are included just because they exist in APT. Ubuntu's current documentation explicitly notes that adding a repository does not automatically make unattended-upgrades select packages from that origin.
Automatic reboot behavior is workload-dependent. Do not enable unattended reboots blindly on a single production node; define a maintenance/redundancy strategy first.
Verify: The APT timers should be scheduled, unattended security upgrades should be enabled according to your policy, the dry run should complete without configuration errors, and third-party origins should be consciously included or excluded.
Step 8 — Remove or disable only services you have positively identified as unnecessary
Audit listeners again:
Review enabled services:
systemctl list-unit-files --type=service --state=enabled
Inspect a service before disabling it:
systemctl status service-name
systemctl cat service-name
Only after confirming it is not needed:
sudo systemctl disable --now service-name
Review package cleanup candidates without immediately deleting them:
sudo apt autoremove --dry-run
Do not use a generic hardening list to remove packages, disable IPv6, disable ICMP, turn off forwarding, or change kernel parameters without understanding the server role. Those actions can break containers, VPNs, routing, monitoring, clustering, cloud networking, and application dependencies.
For each public listener, answer four questions:
- Does the service need to run?
- Does it need to listen on this interface?
- Does it need a public firewall rule?
- How is it authenticated and updated?
Verify: Every remaining listening service and enabled boot service should have a documented operational purpose, and no unreviewed package/service should have been removed merely because it looked unfamiliar.
Step 9 — Keep AppArmor enabled and inspect confinement status
AppArmor is Ubuntu's mandatory access control system. Current Ubuntu documentation states that it is installed and loaded by default, and strongly recommends using it as a defense-in-depth layer.
Check its state:
Also check the profile-loading service:
systemctl is-enabled apparmor.service
systemctl status apparmor.service --no-pager
Review recent AppArmor denials:
sudo journalctl -k -g 'apparmor="DENIED"' --since '24 hours ago' --no-pager
Do not disable AppArmor globally because one application fails. Ubuntu 24.04 integrates AppArmor more deeply into the kernel than older releases, and disabling it reduces the system's security posture. First determine which profile denied which operation, then make a narrow local profile adjustment when the application genuinely requires that access.
Optional profile-management tools are available through:
sudo apt install apparmor-utils
Do not force experimental profiles into enforcement on a production application without testing. complain mode is useful when developing or validating a profile; enforce mode blocks violations.
Verify: aa-status should show AppArmor loaded with profiles present, and any DENIED events should be understood rather than worked around by disabling AppArmor wholesale.
Step 10 — Audit administrator accounts, SSH keys, and stale access
List users with interactive shells:
awk -F: '$7 !~ /(nologin|false)$/ {print $1, $6, $7}' /etc/passwd
Review sudo access:
Find authorized-key files under normal home directories:
sudo find /home -maxdepth 3 -type f -name authorized_keys -print
Review each administrative user's key file with line numbers, for example:
sudo nl -ba /home/deploy/.ssh/authorized_keys
Disabling a user's Unix password does not revoke an already-authorized SSH public key. When removing access, audit both the account and its authorized_keys, active sessions, deployment systems, CI/CD credentials, API tokens, and any external identity source.
If multiple people administer the server, use individual accounts/keys rather than one shared private key.
Do not add an OpenSSH AllowUsers/AllowGroups rule until you have inventoried every required human and automation identity; an incomplete allowlist is a common lockout source.
Verify: Every interactive account, sudo member, and authorized SSH key should map to a current owner or automation purpose, with stale access removed through a tested change process.
Step 11 — Add Fail2Ban only when it solves a real log-based attack surface
Fail2Ban is optional. It watches authentication/log events and temporarily blocks sources that repeatedly match failure patterns. It is not a substitute for SSH keys, firewall restrictions, timely updates, or upstream DDoS controls.
If your public SSH service receives repeated authentication noise and Fail2Ban fits the threat model, use the dedicated tutorial:
Install Fail2Ban on Ubuntu 24.04 for SSH Protection
At minimum, if Fail2Ban is already installed, verify the service and SSH jail:
systemctl is-active fail2ban
sudo fail2ban-client status
sudo fail2ban-client status sshd
Review its recent logs:
sudo journalctl -u fail2ban --since '24 hours ago' --no-pager
Do not infer security quality from raw ban counts. Internet scanning levels, SSH exposure, authentication methods, jail thresholds, and source networks all change how many bans a server records.
Verify: If Fail2Ban is part of your design, it should be active with only intended jails; if it is not part of your design, SSH should still be protected by the stronger primary controls already configured.
Step 12 — Review time, authentication logs, failed units, disk, and security signals
Correct time is required for authentication, TLS validation, logs, package metadata, and incident reconstruction.
Check synchronization:
Review SSH events:
sudo journalctl -u ssh --since '24 hours ago' --no-pager
Review warning-or-higher system messages:
sudo journalctl -p warning --since '24 hours ago' --no-pager
Check failed services:
Check disk and inode pressure:
Review recent logins:
If your environment has centralized logging or monitoring, forward security-relevant events off the server so an attacker with root access cannot silently erase the only copy. Define alerts around unexpected service failures, disk exhaustion, authentication anomalies, newly exposed ports, and backup failures rather than relying on manual review alone.
Verify: Time synchronization should be healthy, there should be no unexplained failed services or disk exhaustion, and suspicious authentication/system events should have an investigation path.
Step 13 — Prepare backups and a recovery path before declaring the server hardened
A security change can be correct and still cause an outage. A hardened server needs a recovery path that does not depend on the same control you might accidentally break.
Before production use:
- keep application/data backups on an appropriate schedule;
- retain at least one recovery copy outside the VM;
- protect backup credentials separately from application credentials;
- test restoring critical data and configuration;
- document the SSH port, UFW policy, administrator identities, and important service paths;
- verify a provider console, rescue environment, or equivalent out-of-band path before removing the last known-good SSH method.
Raff Data Protection can provide an infrastructure-level recovery layer. Application-aware backups remain necessary for databases and workloads that need consistency beyond a VM-level recovery point.
Do not call a backup strategy complete merely because jobs report success. Restore testing is what proves that the backup is usable.
Verify: You should know how to regain administrative access after an SSH/UFW mistake and how to restore the server's critical application data/configuration from a separate recovery copy.
Step 14 — Run the final Ubuntu server hardening checklist
Verify SSH syntax and effective settings:
sudo sshd -t
sudo sshd -T | grep -E \
'^(permitrootlogin|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|maxauthtries|logingracetime) '
Verify firewall and network exposure:
sudo ufw status numbered
sudo ufw show listening
sudo ss -tulpn
Verify AppArmor:
Verify automatic updates:
systemctl list-timers apt-daily.timer apt-daily-upgrade.timer --all
sudo unattended-upgrade --dry-run
Verify accounts and service health:
getent group sudo
systemctl --failed
Check pending package updates and reboot state:
sudo apt update
apt list --upgradable 2>/dev/null
test -f /var/run/reboot-required && \
cat /var/run/reboot-required || \
echo "No reboot required"
Finally, test from another machine:
- a fresh key-based SSH session works;
- password-only SSH does not provide an unintended login path;
- required public application ports work;
- unnecessary ports are not reachable;
- backup/recovery access is documented and tested.
This tutorial is a practical baseline, not a certification. If your organization must meet CIS, FIPS, PCI DSS, HIPAA, SOC 2 controls, or another standard, map the applicable requirements to the actual system and use supported auditing/compliance tooling. Do not claim compliance simply because a generic server-hardening checklist was completed.
Verify: The server should pass the final checks with no unexplained access, listeners, failed services, pending recovery gap, or security control that exists only on paper.
Troubleshooting
A new SSH session stops working after the hardening change
Keep an existing session open and check:
sudo sshd -t
sudo sshd -T | grep -E \
'^(port|permitrootlogin|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication) '
sudo ufw status numbered
Inspect all snippets because an earlier file may have supplied the first value:
sudo ls -1 /etc/ssh/sshd_config.d/
sudo grep -RniE \
'^(PermitRootLogin|PubkeyAuthentication|PasswordAuthentication|KbdInteractiveAuthentication)' \
/etc/ssh/sshd_config /etc/ssh/sshd_config.d 2>/dev/null
Correct the effective setting before restarting SSH again.
PasswordAuthentication no appears in a file but sshd -T says yes
OpenSSH normally uses the first obtained value for most directives. Another earlier snippet may already define the option. Inspect snippet order and use sshd -T as the authority for the effective configuration.
UFW is active but a Docker container port is still reachable
Docker-published traffic can bypass ordinary UFW host-filter assumptions. Inspect:
docker ps --format 'table {{.Names}}\t{{.Ports}}'
sudo ufw show raw
Bind private container ports to loopback or use Docker-aware/upstream filtering. The full Docker caveat is covered in the UFW tutorial.
unattended-upgrade --dry-run does not include a third-party package
That can be expected. Adding an APT repository does not automatically add its origin to the unattended-upgrades policy. Review /etc/apt/apt.conf.d/50unattended-upgrades and the third-party vendor's supported update approach before modifying allowed origins.
AppArmor blocks an application
Inspect the denial rather than disabling AppArmor:
sudo journalctl -k -g 'apparmor="DENIED"' --since '1 hour ago' --no-pager
Identify the profile and operation, then make a narrow tested profile adjustment if the access is legitimately required.
A security scanner demands many sysctl or SSH crypto changes
Determine which benchmark/profile generated the finding and whether it applies to this server. Security benchmark recommendations can conflict with application functionality and distro defaults. Apply standards-driven changes through a tested compliance process rather than copying them into a general baseline.
Conclusion
You now have an Ubuntu 24.04 hardening baseline built around verified access and reduced attack surface: non-root administration, tested SSH keys, effective OpenSSH configuration checks, deny-by-default UFW, automatic security updates, service/account review, AppArmor confinement, optional Fail2Ban, security logging, and recovery planning.
The most important operational habit is continuous verification. Re-run the listener, firewall, account, update, AppArmor, service-health, and backup checks after application deployments and access changes. Hardening is not a one-time state and it is not synonymous with compliance; it is an ongoing process of keeping the server's privileges and exposed surface aligned with what the workload actually requires.
For adjacent procedures, use Generate SSH Keys on Ubuntu 24.04, Set Up UFW Firewall on Ubuntu 24.04, and Install Fail2Ban on Ubuntu 24.04.
Sources