Server hardening basics are the difference between a box that survives the public internet and one that gets owned in its first hour. I have watched the auth log on a brand-new Ubuntu instance: within minutes of the public IP coming up, automated scanners are hammering port 22 with root/admin/password combinations from the standard credential lists. That is not a targeted attack, it is just background radiation on every IPv4 address. A handful of changes remove most of that risk, and a web developer can do all of them in about 20 minutes: switch SSH to key-only auth, disable root login, put a firewall in front of everything, add fail2ban, and turn on automatic security updates. None of it requires being a security engineer.
Why does a fresh server get attacked so fast?
There is no grace period. Botnets continuously sweep the entire IPv4 space looking for SSH, RDP, exposed databases, and known-vulnerable web apps. The moment your instance answers on port 22, you are in someone's target list. If you allowed password authentication and left a weak or reused password on a real account, a credential-stuffing run will eventually land. This is exactly why a default cloud image that ships with key-based auth and no root password is already doing most of the work for you. Your job is to not undo that, and to close the remaining gaps. If you are standing up your first EC2 instance, I walk through the launch and Security Group setup in my guide to a production-ready EC2 instance — this post picks up at the OS level once you can SSH in.
How do I lock down SSH properly?
SSH is the front door, so it gets hardened first. Four things matter: authenticate with keys only, never passwords; refuse direct root login; do day-to-day work as a non-root user with sudo; and reduce noise from drive-by scanners. Before you touch a single config, confirm you can already log in with your key — if you lock out password auth without a working key, you have just locked out yourself. On a cloud instance you usually already have a key (the .pem or the key you pasted at launch). If you are on a self-provisioned box, create the sudo user and install your public key first.
# Run as root (or with sudo) on the server
adduser deploy
usermod -aG sudo deploy
# Install YOUR public key for that user (paste the contents of your
# local ~/.ssh/id_ed25519.pub). Do NOT copy the private key.
mkdir -p /home/deploy/.ssh
nano /home/deploy/.ssh/authorized_keys # paste the public key, save
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
# From a SECOND terminal, prove it works before changing sshd:
# ssh deploy@YOUR_SERVER_IP
# Only continue once that login succeeds with the key.On Ubuntu 22.04 and 24.04, /etc/ssh/sshd_config ends with the line Include /etc/ssh/sshd_config.d/*.conf. That means the upgrade-safe way to change settings is to drop a file into that directory, not edit the main file. A drop-in survives package upgrades that would otherwise prompt you to merge a modified sshd_config.
# Key-only authentication. The single most important line here.
PasswordAuthentication no
KbdInteractiveAuthentication no
# No direct root login over SSH. Log in as 'deploy', then sudo.
PermitRootLogin no
# Public key auth on, empty passwords off.
PubkeyAuthentication yes
PermitEmptyPasswords no
# Optional: only these users may SSH in at all.
AllowUsers deployValidate the config before reloading — a typo that breaks sshd while you only have SSH access is a self-inflicted lockout. Run sshd -t to test syntax, then reload, and crucially keep your current session open while you open a brand-new one to confirm key login still works.
# 1. Syntax-check the config (no output = good)
sudo sshd -t
# 2. Reload sshd without dropping existing connections
sudo systemctl reload ssh
# 3. KEEP this terminal open. In a NEW terminal, confirm:
# ssh deploy@YOUR_SERVER_IP -> should still work (key)
# ssh root@YOUR_SERVER_IP -> should be refused
# Only close the original session once the new one succeeds.Should I change the SSH port?
Moving SSH off port 22 is security-through-obscurity: it does nothing against a targeted attacker, but it does cut the volume of automated noise in your logs. It is optional, and on modern Ubuntu it has a gotcha worth knowing. Since 22.10 — so on 24.04 — sshd is socket-activated, which means systemd owns the listener and the Port directive in sshd_config is ignored. To actually move the port on 24.04 you edit the socket, not sshd_config: create /etc/systemd/system/ssh.socket.d/port.conf with an empty ListenStream= to clear the default 22, then ListenStream=2222, and run sudo systemctl daemon-reload followed by sudo systemctl restart ssh.socket. Open 2222 in both ufw and the cloud Security Group first, or you lock yourself out. On 22.04 LTS, which is not socket-activated, the old Port 2222 line in the drop-in still works. Personally I leave SSH on 22 and rely on key-only auth plus fail2ban, which is what the rest of this post sets up.
What firewall rules does a web server actually need?
A web server needs to accept exactly three things from the internet: SSH so you can manage it, HTTP on 80, and HTTPS on 443. Everything else — your database, Redis, the app's internal port, metrics endpoints — should never be reachable from outside, regardless of what the app binds to. ufw (the Uncomplicated Firewall) is the standard front-end for this on Ubuntu. Set a default-deny inbound policy, then allow only those three. Note that ufw is a host firewall and complements, not replaces, your cloud Security Group; on AWS the Security Group is your first line and ufw is defence in depth behind it.
# Default: block all inbound, allow all outbound
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH. 'OpenSSH' is an app profile that maps to port 22.
# If you moved SSH to a custom port, use: sudo ufw allow 2222/tcp
sudo ufw allow OpenSSH
# Allow web traffic
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Turn it on (this will warn that it may disrupt SSH - it won't,
# because you just allowed OpenSSH above)
sudo ufw enable
# Confirm what is open
sudo ufw status verboseThe gotcha with the OpenSSH app profile: it is hardcoded to port 22. If you change the SSH port later, ufw allow OpenSSH no longer protects you — you must explicitly allow the new port and then delete the now-useless OpenSSH rule. More than one person (me included, once) has flipped the SSH port, restarted the socket, and been locked out because the firewall was still only allowing 22. On the related topic of outbound rules, if your app suddenly cannot reach an external API or SMTP host on EC2, the cause is usually the Security Group and not ufw — I dig into that in why outbound TCP gets blocked on EC2.
How do I stop SSH brute-force attempts and stay patched?
Two daemons do the unglamorous, high-value work here: fail2ban watches your logs and temporarily bans IPs that fail authentication too many times, and unattended-upgrades installs security patches automatically so a known CVE does not sit unpatched for weeks while you are busy shipping features.
fail2ban for SSH
Install it, then never edit jail.conf directly — it gets overwritten on upgrade. Create jail.local instead, which overrides only the keys you set. On Ubuntu 24.04 the sshd jail reads the systemd journal by default, so this works against the standard OpenSSH unit with no extra log-path configuration.
[DEFAULT]
# Ban for 1 hour after 5 failures within a 10-minute window.
bantime = 1h
findtime = 10m
maxretry = 5
# Never lock out your own management network (optional).
ignoreip = 127.0.0.1/8 ::1
[sshd]
enabled = truesudo apt update
sudo apt install -y fail2ban
# Apply the jail.local you just wrote
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
# See currently banned IPs for the sshd jail
sudo fail2ban-client status sshdAutomatic security updates
unattended-upgrades is preinstalled on Ubuntu Server but not always enabled. Turn it on and it will apply patches from the security pocket on a schedule. The one decision to make consciously is whether to auto-reboot — for a kernel update that needs a reboot, I prefer to schedule it for a low-traffic hour rather than leave it off entirely, because an unpatched kernel that never reboots is only half-fixed.
sudo apt install -y unattended-upgrades
# Interactive prompt: choose 'Yes' to enable automatic updates.
# This writes /etc/apt/apt.conf.d/20auto-upgrades.
sudo dpkg-reconfigure -plow unattended-upgrades
# Optional: edit the policy (which origins, auto-reboot window)
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
# Dry-run to confirm it is working
sudo unattended-upgrade --dry-run --debugAutomatic security updates are not optional on a public server. The vulnerability you do not patch today is the one a scanner weaponises next week, while you are heads-down on a feature.
What about the app itself — least privilege
Hardening the OS is wasted if the application runs as root and a single deserialization or file-upload bug hands an attacker the whole box. Run the app as a dedicated, unprivileged user, turn off services you are not using, and keep secrets out of world-readable files. The .env file is the one people get wrong most often: it holds your database password, API keys, and app encryption key, and it routinely ships as 644 (world-readable), meaning any user or compromised process on the box can read it.
- Run the app as its own user (e.g. www-data for PHP-FPM, or a dedicated deploy user) — never as root.
- Lock down secrets: chmod 640 .env and chown deploy:www-data .env so only the app user and its group can read it, not the whole world.
- Disable services you do not use: sudo systemctl disable --now <service>. Every listening daemon is attack surface.
- Bind databases to localhost: ensure MySQL/PostgreSQL and Redis listen on 127.0.0.1, not 0.0.0.0, so the firewall is a backstop rather than the only thing protecting them.
- Use IAM roles, not long-lived keys, for AWS access from the instance — see my notes on least-privilege IAM roles.
At the framework layer there is a parallel checklist — debug mode, mass-assignment, CSRF, security headers — that complements the OS work here. For Laravel specifically I keep a running application security checklist that covers it. And the canonical reference for the SSH settings above is the official OpenSSH sshd_config manual if you want to go deeper than the four directives I set.
The 20-minute hardening checklist
Run through this on every new box before it serves real traffic. None of it is advanced, and skipping any one line is how servers get popped.
- Created a non-root sudo user and installed my SSH public key for it.
- Verified key login works in a fresh terminal BEFORE disabling passwords.
- Set PasswordAuthentication no and PermitRootLogin no via a sshd_config.d drop-in, ran sshd -t, reloaded.
- Enabled ufw with default-deny inbound, allowing only OpenSSH, 80, and 443.
- Installed fail2ban with a jail.local enabling the sshd jail.
- Enabled unattended-upgrades and confirmed it with a dry run.
- Locked .env to 640 and confirmed the app does not run as root.
- Confirmed databases and caches bind to 127.0.0.1, not the public interface.
This is the floor, not the ceiling — there is plenty more you can layer on, from a host-based intrusion detector to centralised log shipping. But these steps are the ones with the highest return for the least effort, and they close the gaps that automated scanners actually exploit. Once the box is hardened, the next thing you want is to know when something goes wrong on it, which is where lightweight monitoring earns its keep — I cover the cheap tools I actually run in monitoring a production server on a budget. Harden first, then watch. A server you set up in five minutes and never touched again is a liability; one you spent twenty minutes locking down is a server you can stop worrying about.

