A practical Linux server hardening checklist comes down to ten things: key-only SSH with root login disabled, a default-deny firewall, fail2ban on anything exposed, automatic security updates, a non-root user with scoped sudo, sane file permissions, audit logging, minimal running services, tested off-site backups, and monitoring that tells you when something changes. Below is the checklist I run on every new server, with the exact commands. Examples are for Ubuntu 24.04 LTS; Debian 12 is nearly identical, and I note where they differ.
Work through it in order, and keep a second SSH session open while you change SSH or firewall settings. Locking yourself out of a cloud VM is recoverable, but it is an annoying way to spend an hour.
1. Create a non-root user and apply least privilege
Nobody should log in as root day to day. Create a named user, give it sudo, and put your public key in place before you touch SSH settings:
adduser deploy
usermod -aG sudo 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_keysThe permissions matter. If ~/.ssh or authorized_keys is group- or world-writable, sshd refuses the key and logs Authentication refused: bad ownership or modes for directory /home/deploy/.ssh, while the client just sees Permission denied (publickey).
For service accounts (a deploy user for CI, a backup user), do not grant full sudo. Give them exactly the commands they need in a sudoers drop-in, edited with visudo -f /etc/sudoers.d/deploy:
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx, /usr/bin/systemctl restart myappApplication processes should run as their own unprivileged user too, never root. If a web app is compromised, the attacker gets that user’s permissions and nothing more.
2. SSH hardening: keys only, no root login
This is the single most important step on an internet-facing server; password-guessing bots start hitting port 22 within minutes of a new IP going live. Put the settings in a drop-in file rather than editing the main config. One detail that trips people up: sshd uses the first value it reads for each option, and on cloud images /etc/ssh/sshd_config.d/50-cloud-init.conf often sets PasswordAuthentication yes. Name your file so it sorts first:
# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
AllowUsers deployTest the config, confirm the effective values, then reload:
sudo sshd -t
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication|kbdinteractive'
sudo systemctl reload sshNow open a new terminal and log in as deploy before closing the old one. Use Ed25519 keys (ssh-keygen -t ed25519) for anything new.
Changing the port is optional; it cuts log noise but is not real security. If you do, note that Ubuntu 22.10 and later use socket activation for SSH, so after editing Port you need sudo systemctl daemon-reload && sudo systemctl restart ssh.socket, and you must open the new port in the firewall first.
3. Firewall: default deny with ufw or nftables
Allow only what the server actually serves. With ufw on Ubuntu:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable
sudo ufw status verboseufw limit rate-limits SSH to six new connections per 30 seconds from one address, which blunts brute-force attempts on its own. Better still, if your team has static IPs or a VPN, restrict SSH to those: sudo ufw allow from 203.0.113.10 to any port 22 proto tcp. On a cloud provider, mirror the same rules in the security group or cloud firewall so there are two layers.
If you prefer nftables directly (the default backend on Debian 12), a minimal /etc/nftables.conf input chain looks like this:
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
iif lo accept
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
tcp dport { 22, 80, 443 } accept
}
}One warning I give on every Docker host: Docker writes its own iptables rules, and a port published with -p 5432:5432 is reachable from the internet even if ufw denies it. Bind internal services to localhost (127.0.0.1:5432:5432) or do not publish them at all. I cover this in running Docker Compose in production.
4. fail2ban for anything that accepts logins
fail2ban watches logs and bans IPs that fail repeatedly. With key-only SSH it mostly reduces noise, but it earns its place on web logins, mail and anything else password-based.
sudo apt install fail2ban
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
[sshd]
enabled = true
backend = systemd
EOF
sudo systemctl restart fail2ban
sudo fail2ban-client status sshdbackend = systemd matters on Debian 12, which has no /var/log/auth.log by default and logs to the journal only; without it the sshd jail fails to start. Never edit jail.conf directly — package updates overwrite it.
5. Automatic security updates
Most real-world compromises of small servers use known, already-patched vulnerabilities. Unattended-upgrades closes that gap:
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgradesThat writes /etc/apt/apt.conf.d/20auto-upgrades. By default only the security pocket is applied, which is what I want on production. Kernel updates need a reboot to take effect, so decide on a policy: either enable Unattended-Upgrade::Automatic-Reboot "true"; with Unattended-Upgrade::Automatic-Reboot-Time "04:00"; in /etc/apt/apt.conf.d/50unattended-upgrades, or check /var/run/reboot-required in your monitoring and reboot during a maintenance window. Doing neither means running an old kernel indefinitely.
6. Secure Ubuntu server basics: services, permissions and kernel settings
Remove what you do not use
Every listening service is attack surface. List them and disable anything unexpected:
sudo ss -tulpn
systemctl list-unit-files --state=enabledDatabases and caches should listen on 127.0.0.1 or a private interface only. A Redis or MongoDB instance on 0.0.0.0 with no auth is still one of the most common ways servers get owned.
File permissions
Find world-writable files and unexpected SUID binaries:
sudo find / -xdev -type f -perm -0002 -ls 2>/dev/null
sudo find / -xdev -type f -perm -4000 -ls 2>/dev/nullKeep secrets such as .env files at 600, owned by the app user. Web roots should be owned by a deploy user and only readable by the web server process, with write access limited to upload directories.
Kernel network settings
A few sysctl values in /etc/sysctl.d/99-hardening.conf, applied with sudo sysctl --system:
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.tcp_syncookies = 1
kernel.kptr_restrict = 27. Audit logging and monitoring
Hardening reduces the chance of a breach; logging tells you whether one happened. auditd records changes to the files that matter:
sudo apt install auditd
sudo tee /etc/audit/rules.d/hardening.rules >/dev/null <<'EOF'
-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
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
EOF
sudo augenrules --load
sudo ausearch -k identity --start todayLogs on the server itself can be wiped by an attacker with root, so ship them off the box (CloudWatch, a syslog host, or any hosted log service). For monitoring, at minimum alert on disk usage, failed systemd units, SSH logins from new IPs and a pending reboot. Run sudo lynis audit system after hardening; it is a free scanner that gives you a score and a list of remaining suggestions, and I use it to verify nothing was missed.
8. Backups you have actually restored
Backups are part of hardening because ransomware and a bad rm both end the same way without them. The rules I follow: off-site (a different provider or account from the server), encrypted, automated, and with a restore tested at least once. restic to S3-compatible storage is a solid default:
export RESTIC_REPOSITORY=s3:s3.amazonaws.com/my-backup-bucket/web01
export RESTIC_PASSWORD_FILE=/root/.restic-pass
restic backup /etc /var/www /home --exclude-caches
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --pruneDump databases to a file first (pg_dump, mysqldump --single-transaction) rather than copying live data files. Give the backup credentials write access only, with object lock or versioning on the bucket, so a compromised server cannot delete its own backups.
When it is worth getting help
If you run one or two servers and have an afternoon, this checklist gets you most of the way. It is worth getting help when you have a fleet with drifting configs, a compliance requirement to evidence, a server you suspect is already compromised, or simply no one who wants to own patching and log review. My server security hardening service applies this baseline (and the parts that are specific to your stack), documents it, and scripts it so new servers start hardened. For fresh builds, it is part of server setup and configuration. Small jobs are $20/hour; a full hardening project is a fixed price agreed in writing. You can also get an existing server audited before changing anything.
Frequently asked questions
Is changing the SSH port worth it?
It reduces automated log noise, but it is not a security control on its own; scanners find non-standard ports quickly. Key-only authentication, disabled root login and a firewall restricted to known IPs do the real work. Change the port if the noise bothers you, not as a substitute.
Do I still need fail2ban if SSH passwords are disabled?
For SSH it becomes mostly a noise and resource reducer, because keys cannot be brute-forced in practice. It is still worth running for any service that accepts passwords, such as web application logins, mail or FTP.
How often should I re-audit a hardened server?
Re-run a scanner like Lynis and review listening services after any major change and at least quarterly. Configuration drifts as packages, users and applications are added, and new servers built by hand rarely match the original baseline.
Does hardening work the same on Amazon Linux or Rocky Linux?
The principles are identical, but the tools differ: firewalld instead of ufw, dnf-automatic instead of unattended-upgrades, and SELinux enabled by default, which you should keep in enforcing mode rather than disabling.