You lock down a Linux server in the first hour after it is delivered, not later. Any machine with a public IP address is under fire from the very first minute by bots that guess passwords and probe ports — they aren't looking for you, they sweep the entire IP address space. Almost all of them are automated and shallow, and a checklist is enough to stop them. Below we unpack that Linux server hardening checklist step by step, together with the traps that genuinely trip people up on today's distributions.
Where does Linux server security start? Three kinds of attacker
The malicious traffic hitting an ordinary server almost always takes one of three forms; know which one you are defending against.
Mass scanning and password guessing. Botnets sweep IP ranges and try leaked passwords against every SSH service and admin panel they find. This category carries the most volume and has the cheapest defense: logging in with a key instead of a password.
Exploiting a public vulnerability. When a flaw in OpenSSH or WordPress is published, scanners are hunting for unpatched versions within hours. The defense here is speed.
Your own misconfiguration. A database listening on every interface, a dashboard with no password, a backup file left in the web root. No vulnerability is involved; the server is doing exactly what you told it to do.
1) Update the system and automate security patches with unattended-upgrades
Most successful break-ins exploit vulnerabilities that were patched months earlier but never installed:
apt update && apt upgrade -y
Running it once doesn't make a server secure, though; repeating it automatically does. With its default configuration, the unattended-upgrades package in practice installs only security updates — the -updates line in /etc/apt/apt.conf.d/50unattended-upgrades is commented out from the start — so the risk of breaking services is low:
apt install unattended-upgrades -y
dpkg-reconfigure -plow unattended-upgrades
unattended-upgrade --dry-run --debug
# sample output on Ubuntu 24.04
Checking: openssh-server ([<Origin component:'main' archive:'noble-security' origin:'Ubuntu' label:'Ubuntu' site:'security.ubuntu.com' isTrusted:True>])
Packages that will be upgraded: openssh-server libssl3t64
One point that often gets missed: a kernel update doesn't take effect until the server reboots. On Ubuntu, the file /var/run/reboot-required means the server is waiting for a reboot, but that file is created by a hook in the update-notifier-common package, which Debian doesn't have. On Debian, then, the setting below never fires unless you install reboot-notifier or check for yourself with needrestart -r l:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
On Ubuntu LTS there is one more free layer: Ubuntu Pro is free on up to five machines and extends security coverage from five years for Main to ten years for the entire archive.
2) Don't work as root: create a sudo user
Doing everyday work as root is like welding without goggles: every typo and every script copied from the internet has full control of the system.
adduser admin
usermod -aG sudo admin # Debian and Ubuntu
usermod -aG wheel admin # AlmaLinux and Rocky
The rest of this guide assumes Debian or Ubuntu; on AlmaLinux and Rocky the equivalents are dnf, firewalld, and the service name sshd instead of ssh.
The real benefit of sudo isn't only preventing mistakes; it's the audit trail. Every command is recorded in the journal with the user's name and the time — the only way to answer "who changed this?". Stay away from NOPASSWD: sudo without a password means any process running as your user is effectively root. For brevity, the code blocks that follow leave out sudo.
3) SSH key authentication instead of passwords: the most important step on this list
If you were going to do only one thing from this list, this would be it. A password can be guessed, and stolen by phishing; a private key stays on your own device. Creating a key is covered in detail in our guide to connecting over SSH and creating keys; in short:
ssh-keygen -t ed25519 -C "laptop-2026"
ssh-copy-id admin@203.0.113.10
Ed25519 is the default choice today: shorter and faster than RSA. Always set a passphrase — if your laptop is lost, it makes the key worthless to whoever finds it.
Now disable password login
Once you have logged in with the key, set the following in /etc/ssh/sshd_config:
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
Take the second line seriously. On Debian and Ubuntu it is already no, but on other distributions — or if a file in sshd_config.d/ has switched it back on — disabling only PasswordAuthentication leaves a second route for passwords. Write it out explicitly, and validate the configuration before restarting:
sshd -t && systemctl restart ssh
Because of the &&, the restart doesn't run if sshd -t finds a syntax error — and that one habit closes off the most common way of locking yourself out. Restarting sshd doesn't drop sessions that are already open. If sshd on your server uses socket activation (section 5), run systemctl restart ssh.socket instead.
If you hit Permission denied (publickey) after disabling password login, see our guide to fixing Permission denied (publickey) for the cause and the way back in.
4) Which sshd_config lines should you actually change?
Hardening sshd can go on forever, but the returns fall off after the first few lines. These are those few lines:
| Setting | Common default | Recommended value | What it shuts out |
|---|---|---|---|
| PasswordAuthentication | yes | no | Makes dictionary attacks and leaked passwords useless |
| KbdInteractiveAuthentication | no on Debian and Ubuntu (OpenSSH default: yes) | no | The second password route; a drop-in file in sshd_config.d/ can reopen it |
| PermitRootLogin | prohibit-password | prohibit-password or no | Direct login to the one user whose name everybody knows |
| MaxAuthTries | 6 | 3 | Attempts per connection — every key the client offers counts as one attempt; with IdentitiesOnly yes, send only the right key |
| LoginGraceTime | 120 | 20 | Half-open connections that tie up sshd's resources |
| AllowUsers | Not set | A list of allowed users | Logins by any other user on the system, even with a valid key |
| X11Forwarding | yes (on Ubuntu) | no | A feature that, on a server without a desktop, is nothing but attack surface |
After editing, ask sshd itself instead of guessing; the -T switch prints the final effective configuration:
sshd -T | grep -Ei 'passwordauth|permitroot|maxauthtries|kbdinteractive'
maxauthtries 3
permitrootlogin prohibit-password
passwordauthentication no
kbdinteractiveauthentication no
One warning: many current distributions put drop-in files in /etc/ssh/sshd_config.d/, and the Include line at the top of the main file reads them first. Because the first value OpenSSH reads is the one that wins, a setting at the end of the main file may never take effect.
5) How much does changing the SSH port help — and why doesn't it work on its own on current Ubuntu?
Let's be honest first: moving SSH from port 22 to 2222 doesn't add real security, and any serious scanner will find the service anyway. What it does is take the mass scans — which only hit port 22 — out of your logs. When you don't have several thousand "wrong password" lines a day, the one unusual line stands out.
There's a trap here, though, that can eat hours. Since Ubuntu 22.10, sshd starts through socket activation: ssh.socket holds the port and launches sshd only when a connection arrives. Changing Port and restarting ssh.service therefore does nothing at all. First check which mode you're in:
systemctl is-enabled ssh.socket
enabled
ss -tlnp '( sport = :22 )'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("systemd",pid=1,fd=78))
That pid=1 is the giveaway: systemd is holding the port, not sshd. On Ubuntu 24.04 a systemd generator reads the port dynamically from sshd_config, but only when you reload and restart the socket unit.
Ubuntu 26.04 LTS uses socket activation by default as well. There OpenSSH is at version 10.2, and a new alias, sshd.service, has been added for ssh.service — if a script or filter depends on the unit name, review it. The same release replaced the default sudo with sudo-rs, but the group is still called sudo and the commands in section 2 don't change.
Set the port itself in /etc/ssh/sshd_config with the line Port 2222 — the rest of this guide assumes 2222 — and then take one of these two routes:
# /etc/ssh/sshd_config
Port 2222
systemctl daemon-reload
systemctl restart ssh.socket # apply the new port in socket mode
# or go back to the classic service entirely
systemctl disable --now ssh.socket
rm -f /etc/systemd/system/ssh.service.d/00-socket.conf
rm -f /etc/systemd/system/ssh.socket.d/addresses.conf
systemctl daemon-reload
systemctl enable --now ssh.service
Make sure you remove those two drop-in files: as long as 00-socket.conf is in place, Ubuntu keeps ssh.service tied to the socket and the switch back never happens. Then confirm with ss -tlnp '( sport = :2222 )' that this time sshd itself holds the port, not pid=1.
6) UFW firewall setup: anything you didn't deliberately open stays closed
ufw is the simplest way to reach a "deny everything unless explicitly allowed" policy. It is inactive when installed and enforces nothing until you enable it:
apt install ufw -y
ufw default deny incoming
ufw default allow outgoing
ufw limit 2222/tcp comment 'ssh'
ufw allow 80,443/tcp comment 'web'
ufw --force enable
A plain ufw enable over an SSH session asks "Command may disrupt existing ssh connections" and waits for an answer; if you paste the whole block at once, the next line answers for you. Use --force only when you have added the rule for your SSH port above it. We also used limit for SSH rather than allow: any IP that opens six connections within 30 seconds is blocked before it ever reaches sshd. Restrict private services to your own IP as well:
ufw allow from 203.0.113.5 to any port 5432 proto tcp comment 'db from office'
ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
2222/tcp LIMIT IN Anywhere # ssh
80,443/tcp ALLOW IN Anywhere # web
5432/tcp ALLOW IN 203.0.113.5 # db from office
2222/tcp (v6) LIMIT IN Anywhere (v6) # ssh
80,443/tcp (v6) ALLOW IN Anywhere (v6) # web
Read two things in this output. disabled (routed) means IP forwarding is off — on a server running Docker, this same line becomes deny (routed). The (v6) rows are the IPv6 mirror of the same rules; if you don't see them, IPV6=yes is switched off in /etc/default/ufw and the firewall only protects IPv4.
ufw deny 8080 has no effect and the port stays open to the entire internet, while ufw status gives you false confidence. The fix: publish ports that only the server itself needs as 127.0.0.1:8080:80 — the pattern explained in our guide to Docker on a cloud server.7) Fail2ban setup, and the trap that stops it starting on Debian 12
Fail2ban reads logs and blocks any IP with too many failed attempts on the firewall for a while. Don't touch jail.conf — it is overwritten on every update. Put your settings in jail.local and restart the service after writing it:
apt install fail2ban python3-systemd -y
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 4
[sshd]
enabled = true
backend = systemd
port = 2222
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd + _COMM=sshd-session
systemctl restart fail2ban
Four failures within ten minutes and a one-hour ban: enough for an automated attacker, and short enough that if you ban yourself by mistake it lifts without a support ticket. We also put backend = systemd inside [sshd] on purpose. In [DEFAULT] — which is what Debian itself does, from version 1.0.2-3, in jail.d/defaults-debian.conf — every jail reads the journal, and the ones whose logs are plain files (WordPress, Apache, a control panel) report "enabled" while silently banning nobody.
fail2ban-client status sshd
Status for the jail: sshd
|- Filter
| |- Currently failed: 2
| |- Total failed: 3184
| `- Journal matches: _SYSTEMD_UNIT=ssh.service + _COMM=sshd + _COMM=sshd-session
`- Actions
|- Currently banned: 7
|- Total banned: 412
`- Banned IP list: 203.0.113.44 198.51.100.77 192.0.2.19
The Total failed number is the most important thing to check: if it is still zero after a few hours, the filter isn't seeing anything and the jail is purely decorative. Lifting a ban is a single command: fail2ban-client set sshd unbanip 203.0.113.5
/var/log/auth.log doesn't exist and the logs live only in the journal. The fix recorded in Debian bug 1037437 is the block above. We didn't spell out the journalmatch line for nothing, either: according to bug 1061776, bookworm's version 1.0.2-2 names the unit sshd.service in filter.d/sshd.conf, while on Debian it is ssh.service. For now the _COMM=sshd branch rescues it, but from OpenSSH 9.8 onward each session's process is called sshd-session, and that branch dries up too. Debian 13, which ships 1.1.0, gets this line right out of the box.8) Extra services and open ports: shrink the attack surface
Every port listening on a public interface is a potential door:
ss -tulpn
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
tcp LISTEN 0 128 0.0.0.0:2222 0.0.0.0:* users:(("sshd",pid=812,fd=3))
tcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1330,fd=6))
tcp LISTEN 0 511 [::]:80 [::]:* users:(("nginx",pid=1330,fd=7))
tcp LISTEN 0 80 0.0.0.0:3306 0.0.0.0:* users:(("mariadbd",pid=1104,fd=23))
tcp LISTEN 0 511 127.0.0.1:6379 0.0.0.0:* users:(("redis-server",pid=944,fd=6))
udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=701,fd=13))
The local address column is the one that matters. 127.0.0.1 means the service can only be reached from inside the server — Redis in the example above is safe. 0.0.0.0, on the other hand, means the entire internet, and [::] is the IPv6 equivalent, so don't look for 0.0.0.0 alone. The MariaDB row is the classic mistake — a database has no reason to be visible from outside: set bind-address to 127.0.0.1 in /etc/mysql/mariadb.conf.d/50-server.cnf. The same logic applies to any dashboard without authentication.
systemctl list-units --type=service --state=running
systemctl disable --now rpcbind.socket rpcbind.service # disable the socket too
apt purge vsftpd -y
The trap from section 5 applies here as well: some services have a separate .socket unit, and if you disable only the .service, the first incoming connection starts it right back up. Remove services you don't use completely.
9) Backups: the worst case is "the data is gone", not "the server was hacked"
A hacked server can be rebuilt; lost data can't. In the panel for Iran cloud servers, automatic backups are an add-on: if they are available in your server's location, you turn them on with a single switch and they are billed by the hour. Because a restore overwrites the entire disk, restores are done through a support ticket. On international servers it depends on the provider, and not every location offers it. Don't rely on either one alone:
0 3 * * * /usr/bin/rsync -az /var/www/ backup@198.51.100.20:/srv/backup/www/
# on the backup server, in the backup user's authorized_keys
# (rrsync is installed in /usr/bin from rsync 3.2.4 onward)
restrict,command="rrsync -wo -no-del /srv/backup/www" ssh-ed25519 AAAA…
Two things make most backups useless. First: if the server itself can delete files at the destination, your backup is within the attacker's reach too — and deleting backups is the first thing ransomware does. Leaving --delete out of the cron line gives you no protection; whoever takes over the server has the same key. The restriction has to live on the destination side: the second line of the block above binds the key to a forced command that only writes and accepts no deletions. Better still are versioned snapshots that the source server has no access to. Second: a backup that has never been through a test restore isn't a backup, it's a hypothesis. Once a month, restore a random file — the details are in our guide to backing up a Linux server.
10) Log review and monitoring: how to tell if a Linux server is hacked
Ten minutes of log reading a week exposes an attack in progress before it succeeds. Start with logins:
journalctl -t sshd -t sshd-session --since today | grep -cE 'Failed password|Invalid user|Connection closed by (invalid|authenticating) user'
loginctl list-sessions # who is connected right now
last -20 # recent successful logins
We deliberately filtered on the syslog identifier rather than using -u ssh: the unit name differs between distributions, and from OpenSSH 9.8 onward every session is logged by the sshd-session process. If you are on Ubuntu 25.10 or 26.04, forget who and lastb as well: systemd there is built without utmp support (because of the 2038 overflow), so who is empty, last reads from the new wtmpdb database, and lastb and btmp don't exist.
If you disabled password login in section 3, sshd no longer writes Failed password lines and attempts are logged as Invalid user instead — so don't read a zero as "no attacks". If something seems off, look for signs of persistence; an attacker almost always creates a new user, adds their own public key or plants a cron job:
awk -F: '$3 == 0 {print $1}' /etc/passwd # any user with UID 0 other than root is suspicious
cat ~/.ssh/authorized_keys /root/.ssh/authorized_keys
for u in $(cut -d: -f1 /etc/passwd); do crontab -lu "$u" 2>/dev/null; done
ps aux --sort=-%cpu | head -5
ss -tunp state established
A CPU stuck at 100% for no reason is the most classic sign of a cryptocurrency miner, and constant outbound connections to unfamiliar IPs point to contact with a command-and-control server. The panel's usage charts show the same sustained spike from the outside; for long-term trends and automatic alerts, our guide to monitoring Linux server resources compares the tools.
The last layer of monitoring comes from the outside: the free website speed and health test checks that your SSL certificate is valid and, if you are behind a CDN, shows whether the origin server's IP can be discovered from the domain's public records. Hiding the origin IP behind Mehran Host's CDN adds a layer in front of the firewall, but it isn't absolute: an old record such as mail that still points at the origin server gives that same address away.
11) How is security different on shared hosting?
If your site runs on cPanel or DirectAdmin shared hosting, most of this list isn't your job: the kernel, sshd, the server firewall and OS hardening are the provider's responsibility. Instead, almost every break-in here comes from the application side: a WordPress plugin or theme that hasn't been updated in months, a weak panel password, installation and backup files left lying around.
One boundary worth knowing: Mehran Host's own customer panel manages mailboxes (create, delete, change password, quota) and forwarders, but it doesn't create email authentication records such as SPF, DKIM and DMARC; you add those yourself as TXT records in the domain's DNS section. The exact split of responsibilities is laid out in our comparison of shared hosting and a VPS.
Linux server hardening checklist at a glance
| Step | Tools | Time |
|---|---|---|
| Updates + automatic patching | apt, unattended-upgrades | 5 minutes |
| sudo user | adduser, usermod | 2 minutes |
| SSH key + disable passwords | ssh-keygen, sshd_config | 10 minutes |
| Harden sshd | sshd -t, sshd -T | 10 minutes |
| New SSH port | sshd_config, ssh.socket | 5 minutes |
| Firewall | UFW | 5 minutes |
| Password-guessing protection | Fail2ban | 5 minutes |
| Remove extra services | ss, systemctl | 10 minutes |
| Backups + restore test | Panel, rsync, cron | 15 minutes |
| Log review | journalctl, loginctl, ps | Ongoing |
| Monitoring | The panel's usage charts + an external uptime service | 5 minutes |
Security isn't a destination, it's a habit — but this list alone moves you out of the "easy target for automated scripts" category. The next step? Get your website up and running with Nginx and HTTPS.
Frequently asked questions
What is the most important step in securing a Linux server?
Replacing password login with SSH keys is by far the most important step. Automated password guessing makes up most of the attack traffic that hits any public server, and closing that route neutralizes the whole category. Create an Ed25519 key, put it on the server, test a successful login in a separate terminal, and only then disable both password authentication and keyboard-interactive authentication in sshd.
Does changing the SSH port really improve security?
Not as a real security layer, because any serious scanner probes every port and finds the service anyway. The benefit is operational: mass scans that only try port 22 disappear from your logs, so genuinely suspicious events stand out. On Ubuntu 22.10 and later — up to and including 26.04 LTS — sshd uses socket activation, so a new port doesn't take effect until you restart the socket unit.
Do I still need Fail2ban if I log in with SSH keys?
For SSH itself it matters much less, because the attempts it blocks could never have succeeded in the first place. Fail2ban stays useful wherever you run a service that accepts passwords: a WordPress login, an admin panel, a mail service, or any login form exposed to the internet. A limit rule on the firewall also does a similar job, stopping repeated connections before the traffic even reaches the service.
How can I tell if my Linux server has been hacked?
Look for signs of persistence, not just the login log. Check for users with UID 0, read authorized_keys for unfamiliar keys, list every user's cron jobs, and look at heavy processes and established outbound connections. A CPU stuck at 100% for no reason is the most common sign of a cryptocurrency miner. If an intrusion is confirmed, don't try to clean the server; pull the data off and reinstall the operating system.
Do I need to do all this on shared hosting too?
No, the infrastructure part of this list is the provider's job: the kernel, the sshd configuration, the server firewall and OS hardening are all out of a shared-hosting customer's reach. Your share is the application layer, which is where most break-ins happen anyway: keeping your CMS core and plugins up to date, using unique passwords for the panel and database, deleting leftover installation and backup files, and turning on two-factor authentication.