Most Linux servers that get compromised weren’t broken into through a zero-day. They had SSH open to the world with password authentication, a root account you could log into directly, outdated packages with known CVEs, or services running that nobody needed. This checklist covers the practical hardening steps I apply to every production Linux server before it goes live.
These steps apply to Debian and Ubuntu. Most translate directly to RHEL-based distributions with minor command differences.
SSH: the first and most important surface
If your server has a public IP, port 22 is being scanned constantly. The goal is to make SSH unusable for attackers without a key.
Disable password authentication. Edit /etc/ssh/sshd_config:
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
Before doing this, make sure your SSH key is in ~/.ssh/authorized_keys and you can log in with it. Locking yourself out of a cloud server is a bad afternoon.
Disable root login. Direct root SSH is a single-factor compromise away from full control of the machine:
PermitRootLogin no
Use a regular user account and sudo for privileged operations. This adds one layer of separation and means every privileged action shows up under a named user in logs.
Restrict which users can log in. If only two accounts ever need SSH access, say so:
AllowUsers deploy peter
Anything not in that list cannot authenticate, regardless of what keys they have.
Restart SSH after changes. Always test the new config before restarting:
sshd -t # test config syntax
systemctl restart ssh
Keep your current session open and open a second session to confirm access before closing anything.
On port changing. Changing SSH to a non-standard port does reduce noise in logs, but it is security through obscurity. Anything listening on a public IP will be found by scanners within hours. Fix the real issues first. If you do change the port, update your firewall rules and any deployment scripts before restarting.
fail2ban: block brute-force attempts
fail2ban watches log files and temporarily bans IPs that trigger repeated failures. For SSH it is standard practice:
apt install fail2ban
Create /etc/fail2ban/jail.local with at minimum:
[sshd]
enabled = true
port = ssh
maxretry = 5
bantime = 3600
findtime = 600
This bans any IP that fails 5 times within 10 minutes for one hour. Adjust aggressively for internet-facing servers. I typically set bantime = 86400 on servers that don’t need access from broad IP ranges.
Check the status with fail2ban-client status sshd. You’ll see banned IPs and the hit count. On a freshly exposed server the numbers are sometimes sobering.
UFW firewall: default deny
Ubuntu ships with UFW, which wraps iptables into something manageable. The approach is default deny inbound, allow only what’s needed:
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
If the server is a mail server, add the relevant SMTP/submission ports. Everything else stays closed.
Review your rules periodically. ufw status numbered shows the full ruleset. Old rules for services that no longer exist are common in long-running servers.
For servers that only need SSH access from a known IP range, restrict it:
ufw allow from 203.0.113.0/24 to any port 22
This eliminates SSH exposure entirely from outside that range.
Automatic security updates
Unpatched packages are the second most common way servers get owned, after weak credentials. On Debian/Ubuntu, unattended-upgrades handles security patches automatically:
apt install unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
Check /etc/apt/apt.conf.d/50unattended-upgrades to confirm the security origin is enabled. By default it is, but verify:
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
"origin=Ubuntu,codename=${distro_codename}-security,label=Ubuntu-Security";
};
Set automatic reboots if a kernel update requires it. For servers where uptime matters, schedule the reboot window:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
This is a judgment call. Some environments need manual approval for reboots. At minimum, the security packages should apply automatically.
User and sudo hygiene
No shared accounts. Every person with access gets their own account. When someone leaves, you disable their account rather than changing a shared password.
Sudo logging. By default, sudo commands are logged to /var/log/auth.log. Make sure log rotation isn’t discarding these too quickly. On servers where compliance matters, increase the retention period in /etc/logrotate.conf.
Audit sudo group membership. On Ubuntu, members of the sudo group have full root access. Check who’s in it:
getent group sudo
Remove any accounts that shouldn’t be there. Developer accounts on a production server should use specific sudo rules for what they actually need, not blanket access.
Lock inactive accounts. passwd -l username locks an account without deleting it. usermod --expiredate 1 username expires it entirely.
Disable unused services
Every running service is an attack surface. Check what’s listening:
ss -tlnp
Compare this against what you expect. Web server, SSH, your application. Anything else needs a reason to be there.
Common things to disable on a server that doesn’t need them:
systemctl disable avahi-daemon
systemctl disable cups
systemctl disable bluetooth
avahi-daemon (mDNS) and cups (printing) are enabled by default on some distributions. Neither belongs on a production server.
Check services that start automatically:
systemctl list-unit-files --state=enabled
Disable anything unfamiliar that has no clear purpose.
SUID/SGID audit
SUID binaries run with the file owner’s permissions, regardless of who executes them. SGID does the same for group. These are necessary for some system utilities (passwd, ping, sudo itself), but unknown SUID binaries in unexpected locations are a red flag:
find / -perm -4000 -type f 2>/dev/null
find / -perm -2000 -type f 2>/dev/null
Save this output on first run and compare it periodically. New SUID binaries appearing on a running server without an explanation warrant investigation.
If you find SUID set on something that doesn’t need it:
chmod u-s /path/to/binary
Audit logging with auditd
auditd records system calls and file access at the kernel level. It is useful for compliance requirements and for forensics after an incident.
apt install auditd audispd-plugins
A basic ruleset covers the most important events. In /etc/audit/rules.d/audit.rules:
# Track authentication
-w /etc/passwd -p wa -k auth
-w /etc/shadow -p wa -k auth
-w /etc/sudoers -p wa -k privilege
# Track sudo usage
-a always,exit -F arch=b64 -S execve -F uid=0 -k root_commands
# Track SSH configuration changes
-w /etc/ssh/sshd_config -p wa -k sshd_config
After adding rules:
augenrules --load
systemctl restart auditd
Query logs with ausearch -k auth or ausearch -k privilege. On a healthy server these logs are quiet. Unexpected writes to /etc/passwd or /etc/sudoers at 3am are not quiet.
ClamAV in hosted environments
On dedicated servers and VPS environments that handle user-uploaded files, or where clients send files inbound, ClamAV provides a baseline for malware scanning. It is not a substitute for proper access controls but it catches known malware in uploads:
apt install clamav clamav-daemon
freshclam
The free ClamAV database is updated several times a day. Configure clamd to run as a daemon and integrate it with your application’s file handling, or run periodic scans on upload directories:
clamscan -r /var/www/uploads --log=/var/log/clamav/uploads.log
For environments without user-generated content, ClamAV adds overhead without much benefit. Apply judgment.
Putting it together
Running through this checklist on a new server takes about 45 minutes. On existing servers that have been running for a while without review, allow longer. The service audit and SUID check often surface things that need investigation.
The checks that matter most, in order: key-only SSH, disabled root login, fail2ban enabled, UFW default deny, and automatic security updates. Everything else reduces the attack surface further but those five cover the majority of real-world compromises.
For a broader view of your security posture, the Security Health Check covers network exposure, authentication configuration, and application-layer issues. The Website Security Scanner checks public-facing infrastructure for common misconfigurations.