A production Linux server hardening checklist that will not break your application

Generic hardening scripts break things, get reverted, and leave the server less secure than a careful subset would have. This is the order we apply changes in, and why each one comes when it does.

There are two ways to fail at server hardening.

The first is not doing it, which leaves a machine running with whatever the provider’s default image shipped: services nobody uses, password authentication enabled, and logging that records almost nothing you would want after an incident.

The second is running a hardening script that applies a hundred changes from a generic benchmark in one pass. Something breaks, nobody can tell which of the hundred did it, the whole thing gets reverted, and hardening is written off as disruptive. The server ends up less secure than a careful subset would have made it, and now there is organizational resistance to trying again.

This checklist is ordered to avoid the second failure. Each group is small enough to verify before moving on.

Commands are given for both families: Debian and Ubuntu, and Rocky Linux, AlmaLinux and RHEL. To see where a server stands before you start, the read-only audit script near the end checks most of this list in under a minute and changes nothing.

The checklist at a glance

#CheckVerify with
1Console access works, and a second session is openLog in through the provider’s console
2Baseline of sockets, packages, units, shells and sudo saved off the serverThe files exist somewhere else
3SSH keys only, password login offsshd -T | grep passwordauthentication
4Root login off, or key-onlysshd -T | grep permitrootlogin
5Every authorized key has a named ownerRead each authorized_keys file
6Named accounts, no shared logins/etc/passwd and /etc/sudoers.d/
7Every listening port justifiedss -tulpn
8Databases and caches bound to localhostss -tulpn shows 127.0.0.1 for them
9Default-deny firewallufw status verbose or firewall-cmd --list-all
10Origin accepts web traffic only from your CDNA curl --resolve test from outside
11Unused packages and services removedsystemctl list-unit-files --state=enabled
12No compilers on a production servercommand -v gcc make prints nothing
13Secrets not world-readablefind /var/www -name wp-config.php -perm -o=r prints nothing
14No code execution in upload directoriesA test .php in uploads returns 403
15setuid list reviewedfind / -xdev -perm -4000 -type f
16SELinux enforcing, or AppArmor enabledgetenforce or aa-status
17systemd sandboxing on your own servicessystemd-analyze security myapp
18auditd recording identity and privilege changesauditctl -l
19Logs shipped off the hostToday’s entries arrive at the destination
20Brute-force protection runningfail2ban-client status sshd
21Clock synchronisedtimedatectl
22Automatic security updatesunattended-upgrades or dnf-automatic enabled
23No reboot pending/var/run/reboot-required or dnf needs-restarting -r
24A backup restored, and the time it took recordedYour restore notes

The rest of this page is the reasoning, the commands and the order.

Step zero: make sure you can get back in

Before touching SSH configuration, confirm you have an independent way onto the machine.

Nearly every provider offers a serial or VNC console. Open it, log in, and leave that window open for the rest of the work. This takes two minutes and is the difference between a mistake and an incident.

# Also worth having: a second SSH session already open and authenticated.
# If you break sshd, the existing session survives and you can fix it.

Locking yourself out of a production server while securing it is a genuinely common outcome. It is also entirely avoidable.

Establish the baseline before you change anything

You cannot tell what is unusual without knowing what is normal. Capture the current state:

# What is listening and what owns it
ss -tulpanH > /root/baseline-sockets.txt

# What is installed
dpkg -l > /root/baseline-packages.txt        # Rocky, Alma, RHEL: rpm -qa

# What runs at boot
systemctl list-unit-files --state=enabled > /root/baseline-units.txt

# Who can log in
awk -F: '$7 !~ /(nologin|false)$/ {print $1": "$7}' /etc/passwd > /root/baseline-shells.txt

# Who has sudo
grep -rE '^[^#]' /etc/sudoers /etc/sudoers.d/ > /root/baseline-sudo.txt

Keep these somewhere off the server. They are the reference for every future “is this supposed to be here” question, and after an incident they are the difference between a diff and an investigation.

Group one: authentication

The highest-value changes, and the ones that need the console open.

Key-based SSH, passwords off

# Confirm your key works BEFORE disabling passwords
ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no you@server 'echo ok'

Only once that prints ok:

# /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
PermitEmptyPasswords no
MaxAuthTries 3

Then validate the configuration before reloading, which is the step that saves you:

sshd -t && systemctl reload ssh      # Rocky, Alma, RHEL: systemctl reload sshd
sshd -T | grep -Ei 'passwordauthentication|permitrootlogin|pubkeyauthentication'

Name the file so it sorts early. sshd uses the first value it reads for each setting, and many cloud images ship a file such as 50-cloud-init.conf that turns password login back on. A 10-hardening.conf is read before it and wins; a 99-hardening.conf would silently lose.

sshd -T prints the effective configuration including defaults, not just what is in your file. Read that, not the file you edited.

Audit existing keys

Every authorized_keys file is a standing grant of access. People leave, laptops get replaced, and nobody ever removes the key.

find / -xdev -name 'authorized_keys' -type f -exec sh -c \
  'echo "== $1"; cat "$1"' _ {} \; 2>/dev/null

Look at the comment field on each key. If you cannot name the person and confirm they still need access, remove it.

Also check whether sshd_config redirects where keys are read from, because if it does, the file you just audited may not be the one being used:

sshd -T | grep -Ei 'authorizedkeysfile|authorizedkeyscommand'

Named accounts, not shared logins

If four people share one login, your authentication log cannot tell you who did anything, and revoking access for one person means changing it for everyone. Give people their own accounts with their own keys and sudo where needed.

Verify before continuing: log out completely, log back in with a key, and run a sudo command.

Group two: network exposure

Most compromises begin with something reachable that did not need to be.

Inventory what is listening, and justify each one

ss -tulpanH | awk '{print $1, $5, $7}' | sort -u

For every line, answer: what is this, does it need to be reachable, and by whom? Anything you cannot justify gets bound to localhost or removed.

The frequent finding here is a database listening on 0.0.0.0. On a provider’s shared internal network that is meaningfully exposed even with no public firewall rule.

# /etc/mysql/mysql.conf.d/mysqld.cnf
bind-address = 127.0.0.1
# /etc/redis/redis.conf
bind 127.0.0.1
protected-mode yes

Default-deny firewall

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'ssh'
ufw allow 80,443/tcp comment 'web'
ufw enable
ufw status numbered

If your administrative interfaces can be restricted by source address, do it. It is a much stronger control than any amount of rate limiting.

ufw allow from 203.0.113.0/24 to any port 22 proto tcp comment 'office'

On Rocky Linux, AlmaLinux and RHEL, firewalld is the default and already denies what is not allowed in the active zone:

firewall-cmd --permanent --add-service=ssh --add-service=http --add-service=https
firewall-cmd --reload
firewall-cmd --list-all

Lock the origin if you use a CDN

If a CDN or WAF sits in front of your site, and your origin still accepts connections from anywhere, the edge protection is optional from an attacker’s point of view. Restrict port 80 and 443 to your edge provider’s published ranges.

This one change defeats the most common way that edge protection is bypassed.

Verify before continuing: load the site, log in over SSH from an allowed address, and confirm the application still reaches its database.

Group three: reduce what exists

Configuration can be wrong. Software that is not installed cannot be exploited.

# What is installed that nothing depends on
apt list --installed 2>/dev/null | wc -l
deborphan 2>/dev/null

# Rocky, Alma, RHEL: count, and preview what nothing needs without removing it
rpm -qa | wc -l
dnf autoremove --assumeno

# Common unnecessary services on a web server
systemctl list-unit-files --state=enabled | grep -Ei 'avahi|cups|rpcbind|nfs|bluetooth'

Compilers, development headers and debugging tools on a production web server are convenient for you and equally convenient for anyone who gets a shell.

Removing is more durable than configuring around. It also cannot drift back.

Group four: permissions and secrets

Find world-readable secrets

# Config files readable by everyone
find /var/www /etc -type f \( -name '*.env' -o -name 'wp-config.php' -o -name '*.conf' \) \
  -perm -o=r -ls 2>/dev/null

wp-config.php containing database credentials, readable by every user on the box, is extremely common on shared setups.

chown root:www-data /var/www/example.com/wp-config.php
chmod 640 /var/www/example.com/wp-config.php

Stop code execution in upload directories

This turns a file upload vulnerability into a nothing instead of into remote code execution.

location ~* ^/wp-content/uploads/.*\.(php|phtml|php[0-9])$ {
    deny all;
    return 403;
}

Look for the surprising things

# Files anyone can write
find / -xdev -type f -perm -o=w -not -path '/proc/*' -ls 2>/dev/null | head -50

# setuid binaries, which is a short list on a normal server
find / -xdev -perm -4000 -type f -ls 2>/dev/null

The setuid list is worth reading properly. Anything unexpected on it deserves an explanation.

Leave SELinux or AppArmor on

The most common hardening step in practice is the reverse of one: SELinux set to permissive or disabled because something did not work, and never switched back. A mandatory access control layer is one of the few things that still constrains a process after it has been compromised.

getenforce        # Rocky, Alma, RHEL: should print Enforcing
aa-status         # Ubuntu, Debian: should list loaded profiles

If an application fails under SELinux, read the denial with ausearch -m avc -ts recent and allow that specific access, rather than turning the whole layer off.

Service isolation with systemd

If your services run under systemd, a few directives cost nothing and meaningfully constrain a compromised process:

# /etc/systemd/system/myapp.service.d/hardening.conf
[Service]
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/myapp /var/log/myapp
ProtectKernelTunables=true
RestrictSUIDSGID=true

Apply these one at a time and restart the service after each. ProtectSystem=strict in particular will break anything that writes outside its declared paths, which is the point, and which is also why you add ReadWritePaths deliberately rather than guessing.

Group five: visibility

Hardening without logging means that after an incident you have a reduced attack surface and no idea what happened.

Record the events that matter

Authentication, privilege escalation, and changes to accounts. On most distributions the defaults capture authentication; privilege escalation and account changes often need auditd, which is installed by default on Rocky, Alma and RHEL and one package away on Debian and Ubuntu (apt install auditd).

Rules added with auditctl disappear at reboot. Put them in a file so they persist:

# /etc/audit/rules.d/50-hardening.rules
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k privilege
-w /etc/sudoers.d/ -p wa -k privilege
-w /root/.ssh/ -p wa -k ssh_keys
augenrules --load
auditctl -l

Keep the clock synchronised while you are here. timedatectl should report the system clock as synchronised; logs from two machines whose clocks disagree cannot be lined up during an investigation.

Send logs off the host

This is the single most important logging decision and the one most often skipped.

Logs held only on the affected machine are logs an intruder with root can edit. Remote logs are the only ones that still mean anything afterwards. It does not have to be elaborate: forwarding syslog to a second machine you control is enough to change what is possible during an investigation.

Brute force mitigation

apt install fail2ban                         # Rocky, Alma, RHEL: from EPEL
# dnf install epel-release && dnf install fail2ban
systemctl enable --now fail2ban
fail2ban-client status sshd

Add your own addresses to the ignore list. The first serious failure mode of any auto-blocking tool is locking out the administrator during an incident.

Group six: patching and recovery

Everything above is undone by a server that is not updated.

# Debian and Ubuntu: unattended security updates, with a record of what was applied
apt install unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
# Rocky, Alma, RHEL: dnf-automatic, set in /etc/dnf/automatic.conf to
#   upgrade_type = security
#   apply_updates = yes
dnf install dnf-automatic
systemctl enable --now dnf-automatic.timer

Automatic updates are good. Automatic updates with nothing checking that services came back up afterwards are a gamble. Pair them with a health check.

Also: applying kernel updates without rebooting means running vulnerable code while the patch log looks healthy.

# Does this machine need a reboot to activate what has been installed
[ -f /var/run/reboot-required ] && cat /var/run/reboot-required   # Debian, Ubuntu
dnf needs-restarting -r                                              # Rocky, Alma, RHEL

If nobody owns this cadence as an explicit part of their job, it slips within months. That is the gap managed VPS support exists to close.

Prove the backup

A backup that has never been restored is a hypothesis. Restore one to a throwaway machine, confirm the data is complete and the application starts, and record how long it took.

That last number is the one nobody has and everybody wants during an incident.

Check a server against this list

The audit script below checks most of items 3 to 23 in the table above on Debian, Ubuntu, Rocky Linux, AlmaLinux and RHEL, and prints PASS, WARN or INFO for each. It is read-only: it installs nothing, changes no setting and sends nothing anywhere. Read it before you run it, as you should with any script from the internet.

curl -fsSLO https://techsteps.ae/downloads/linux-hardening-audit.sh
sha256sum linux-hardening-audit.sh
# 7c4c039136c5ce9df8ac5270b7a079bc0878125a63793cd74e1eab22f0e35f9c
less linux-hardening-audit.sh
sudo bash linux-hardening-audit.sh

Download linux-hardening-audit.sh (version 1.0, 19 October 2026). We tested it on Ubuntu 24.04, Debian 12 and AlmaLinux 9.

A WARN is a question, not an order. Some warnings will be deliberate on your server; those belong on the list of exceptions with the reason, which is the subject of the next section.

What a script cannot do for you

Automated hardening tools and benchmark scripts are useful starting points and poor finishing points, for three reasons.

They do not know your workload. A script cannot know that port 8080 serves an internal API your mobile app depends on, or that a service needs to write to a path outside its own directory.

They cannot tell you what they decided not to do. The most valuable output of a hardening engagement is often the list of controls deliberately not applied, with the reasoning. A script produces no such list.

They apply everything at once. Which is precisely the failure mode that gets hardening reverted.

Use the benchmarks as a source of ideas. Apply them in groups. Verify after each. Write down what you skipped and why. That list of exceptions, with the reasoning, is the main thing a hardening engagement should hand back.

The order, condensed

  1. Confirm console access, and keep a second session open
  2. Capture the baseline before changing anything
  3. Authentication: keys on, passwords off, audit existing keys, named accounts
  4. Exposure: inventory listeners, default-deny firewall, bind internal services to localhost, lock the origin to your edge provider
  5. Reduce: remove packages and services nothing needs
  6. Permissions: secrets not world-readable, no execution in upload directories, systemd sandboxing
  7. Visibility: audit rules, remote log shipping, brute force mitigation
  8. Sustain: patching cadence, reboot when required, a backup you have actually restored

Verify after each group. Write down what you skipped. The list of deliberate exceptions is part of the deliverable, not an admission of incomplete work.

Frequently asked questions

What is Linux server hardening?

Reducing what an attacker can reach and do on a Linux server: key-only SSH, a default-deny firewall, fewer installed services, tighter permissions, logs that leave the machine, and updates applied on a schedule. The goal is a smaller attack surface without breaking what the server is for.

How do I harden a Linux server, step by step?

In this order: secure your way back in, record a baseline, lock down authentication, cut network exposure, remove what is not needed, tighten permissions and secrets, add logging, then keep it patched and prove the backups. Verify the application after each group. The table at the top of this page is the full list.

Is the CIS Benchmark enough?

The CIS Benchmarks are a good source of controls, but they are written for every server and know nothing about yours. Applied wholesale they break things; used as a menu, in groups, with the exceptions written down, they work well.

Can a script harden my server for me?

A script can check a server, as the audit above does, and it can apply settings. It cannot know which open port your application depends on, so it cannot decide what is safe to close. Use scripts to find the gaps and make the changes deliberately.

How often should a hardened server be checked?

After every significant change, and on a schedule, monthly is reasonable, because servers drift: packages arrive, ports open, keys accumulate. Re-running the audit script and comparing against the baseline makes drift a diff rather than an investigation.

Dealing with this yourself?

If this describes a system you are responsible for, we can look at the specific case rather than the general one.