Home About Services Projects Blog Contact Hire me

Hardening a Fresh Linux Server: The First 30 Minutes

A brand new Linux server is a liability until you've spent half an hour on it. Automated scanners find a fresh public IP within minutes — I've watched SSH brute-force attempts start on a box that had been reachable for under ten minutes.

This is what I do between "the provider says it's ready" and "I'm willing to put customer traffic on it". It's ordered by how much risk each step removes per minute spent.

Before you start: open a second SSH session and leave it connected. Every time you change SSH or firewall configuration, test the change in a new connection while the old one is still alive. If you lock yourself out, that session is the difference between a fix and a console ticket.

1. Patch first, everything else second

Provider images are frequently weeks or months old. The most likely way into your server right now is a vulnerability that already has a patch sitting in the repository.

update everything
# RHEL / Rocky / Alma
dnf update -y && dnf install -y epel-release

# Debian / Ubuntu
apt update && apt full-upgrade -y

# Reboot if the kernel changed — check first
needs-restarting -r        # RHEL family
[ -f /var/run/reboot-required ] && echo "reboot needed"   # Debian family

Then make sure security updates keep applying without you:

automatic security updates
# RHEL family
dnf install -y dnf-automatic
sed -i 's/^apply_updates =.*/apply_updates = yes/' /etc/dnf/automatic.conf
sed -i 's/^upgrade_type =.*/upgrade_type = security/' /etc/dnf/automatic.conf
systemctl enable --now dnf-automatic.timer

# Debian family
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades

Security updates only. Unattended full upgrades on a production web server will eventually restart PHP or MySQL at a time you didn't choose. Restrict automation to the security repository and handle feature upgrades in a window.

2. A real user, and no more root logins

Working as root all day means every mistake is maximally destructive and nothing in the audit log tells you which human did it. Create a named account with sudo, put your key on it, and confirm it works before you disable root.

create an admin user with key auth
adduser alan
usermod -aG wheel alan          # 'sudo' group on Debian/Ubuntu

mkdir -p /home/alan/.ssh
cp /root/.ssh/authorized_keys /home/alan/.ssh/   # or paste your public key
chown -R alan:alan /home/alan/.ssh
chmod 700 /home/alan/.ssh
chmod 600 /home/alan/.ssh/authorized_keys

Now — in a new terminal — log in as that user and run sudo -v. Only when that works do you continue.

3. Lock down SSH

SSH is the front door. These four settings remove the overwhelming majority of opportunistic attacks, because almost all of it is automated password guessing.

/etc/ssh/sshd_config.d/99-hardening.conf
# No root logins over the network
PermitRootLogin no

# Keys only — this is the single highest-value line here
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes

# Don't let unauthenticated sessions linger
LoginGraceTime 30
MaxAuthTries 3
MaxSessions 5

# Only these accounts may log in at all
AllowUsers alan

# Disable features you almost certainly don't use
X11Forwarding no
AllowAgentForwarding no
PermitEmptyPasswords no
validate, then apply
sshd -t && systemctl reload sshd   # -t refuses to reload a broken config

On changing the SSH port: it cuts log noise dramatically, which has real value when you're reading logs by hand. It is not a security control — a port scan finds it in seconds. Move it for the quiet, not for the safety.

4. Default-deny firewall

The rule is simple: deny everything inbound, then open only what a real user needs. If you can't name who connects to a port, it shouldn't be open.

firewalld (RHEL family)
systemctl enable --now firewalld
firewall-cmd --set-default-zone=drop
firewall-cmd --permanent --add-service=ssh
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
firewall-cmd --list-all
ufw (Debian family)
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
ufw status verbose

On hosting servers I generally use CSF instead — it wraps iptables with connection tracking, and its LFD daemon handles login-failure blocking, port-scan detection and process tracking in one place.

5. Ban the persistent guessers

Key-only SSH already defeats password attacks, but fail2ban stops the noise and covers the other services — mail, FTP, panel logins — where passwords still exist.

/etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 4
# Never lock yourself out from the office or a jump host
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24

[sshd]
enabled = true

[postfix-sasl]
enabled = true

Set ignoreip before you enable it, not after you've banned yourself. Everyone does this exactly once.

6. Kernel and network parameters

A handful of sysctl values close off spoofing and redirect tricks that have no legitimate use on a normal web server.

/etc/sysctl.d/99-hardening.conf
# Reject spoofed source addresses
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# Ignore ICMP redirects and source routing
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_redirects = 0

# Log packets with impossible addresses
net.ipv4.conf.all.log_martians = 1

# SYN flood resistance
net.ipv4.tcp_syncookies = 1

# Restrict kernel pointer exposure and dmesg
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1

# Protect against symlink/hardlink races in shared /tmp
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
apply
sysctl --system

7. Shrink the attack surface

Every listening service is a door. Find out what's actually listening — the answer on a default image is usually "more than you expected".

what's listening, and why
# Listening sockets with owning process
ss -tulpn

# Services set to start at boot
systemctl list-unit-files --state=enabled --type=service

# Disable what you can't justify
systemctl disable --now rpcbind avahi-daemon cups 2>/dev/null

Then tighten the filesystem. Mount options on /tmp and /dev/shm block a common escalation path: dropping a binary in a world-writable directory and executing it.

/etc/fstab — restrict world-writable mounts
/dev/shm      /dev/shm    tmpfs   defaults,nodev,nosuid,noexec   0 0
/tmp          /tmp        tmpfs   defaults,nodev,nosuid,noexec   0 0

Test noexec on /tmp before you rely on it. Some package managers and installers legitimately execute from /tmp and will fail with confusing errors. Verify updates still work before you consider it done.

8. Logging you'll be glad of later

During an incident, the question is always "when did this start, and what else did they touch?". You can only answer it if you were logging beforehand.

auditd — minimum useful ruleset
# /etc/audit/rules.d/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/ssh/sshd_config -p wa -k sshd
-w /var/log/lastlog -p wa -k logins
-a always,exit -F arch=b64 -S execve -F euid=0 -k root-commands

Just as important: ship logs off the box. Local logs are the first thing an attacker edits. Even a nightly rsync to somewhere they don't have credentials for is vastly better than nothing.

9. Backups, and a restore you have actually tested

Hardening reduces the chance of a bad day. Backups decide how bad the bad day is. The rule I hold to: a backup nobody has restored is a rumour.

  • Backups go somewhere the server cannot reach with its own credentials — otherwise ransomware encrypts them too.
  • Restore one, on a schedule, into an isolated environment. Automate the check; a human will stop doing it by week three.
  • Know your actual RPO and RTO — how much data can you lose, and how long may recovery take? If you can't answer, you don't have a backup strategy, you have backup files.

10. Verify what you've done

post-hardening checks
# Confirm password auth is genuinely off
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no user@host
# expected: "Permission denied (publickey)"

# Confirm the firewall from outside the box
nmap -Pn -p- your.server.ip

# General audit with a scoring tool
lynis audit system

Thirty minutes of this removes the entire class of opportunistic, automated attack that makes up most of what actually hits a server. It won't stop a determined attacker with a zero-day in your application — nothing at this layer will. But it means that when something does happen, it was interesting, and you have the logs to reconstruct it.