This is the short version: ten steps, the commands for each, and a check after every one so you know it took effect. It is meant to be run top to bottom in the first 30 minutes of a server's life, logged in as root. The reasoning behind each step is in how to harden a new Linux VPS in 10 steps; here we only do and verify. Commands are for Debian 12/13 and Ubuntu 22.04/24.04, with AlmaLinux and Rocky 8/9/10 where they differ. Replace deploy and 203.0.113.10 with your own user and address.
1. Update and reboot
# Debian / Ubuntu
apt update && apt full-upgrade -y && reboot
# AlmaLinux / Rocky
dnf upgrade -y && rebootVerify after logging back in:
uname -r # the running kernel
apt list --upgradable # Debian / Ubuntu: nothing listed
dnf check-update # AlmaLinux / Rocky: no packages listed2. Create a sudo user and give it your key
# Debian / Ubuntu (minimal Debian images may need: apt install sudo -y)
adduser deploy
usermod -aG sudo deploy
# AlmaLinux / Rocky
adduser deploy
passwd deploy
usermod -aG wheel deployIf you logged in as root with an SSH key, give the new user the same key:
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_keysIf you logged in with a password, create a key on your own machine and copy it over instead:
ssh-keygen -t ed25519 -C "you@example.com"
ssh-copy-id deploy@203.0.113.10Verify from a second terminal on your own machine, leaving the root session open:
ssh deploy@203.0.113.10
sudo whoami # must print: rootKey types and agent setup are covered in SSH keys and disabling password login.
3. Turn off password and root login
Do not edit sshd_config itself. Use a drop-in file, which survives package upgrades:
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
EOF
sshd -t # no output means the syntax is valid
systemctl restart ssh # Debian / Ubuntu
systemctl restart sshd # AlmaLinux / RockyTip: the file name matters. sshd uses the first value it reads for each setting, and drop-ins are read in alphabetical order. Cloud images often ship a 50-cloud-init.conf containing PasswordAuthentication yes; a file starting with 00- is read before it and wins, one named 99- would silently lose.
AlmaLinux and Rocky 8 do not read sshd_config.d. On those, put the same four lines at the top of /etc/ssh/sshd_config.
Verify what sshd will actually apply, then test from your own machine before closing the root session:
sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication)'
# from your machine: both must be refused
ssh root@203.0.113.10
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password deploy@203.0.113.10Both should end in Permission denied (publickey). A normal ssh deploy@203.0.113.10 must still work. Only then close the root session. If you do lock yourself out, the provider's console still gets you in.
4. Enable the firewall
# Debian / Ubuntu
apt install ufw -y
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable# AlmaLinux / Rocky
dnf install firewalld -y
systemctl enable --now firewalld
firewall-cmd --permanent --add-service=ssh
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reloadLeave out 80 and 443 if the server will not serve web traffic.
Verify:
ufw status verbose # Debian / Ubuntu
firewall-cmd --list-all # AlmaLinux / RockyTip: Docker writes its own packet filter rules. A container port published as 5678:5678 is reachable from the internet even when ufw says it is denied. Publish to 127.0.0.1:5678:5678 when only a local reverse proxy needs it.
5. Install Fail2ban
# Debian / Ubuntu
apt install fail2ban -y
# AlmaLinux / Rocky (the package is in EPEL)
dnf install epel-release -y
dnf install fail2ban -yWrite the jail explicitly instead of relying on distribution defaults:
cat > /etc/fail2ban/jail.local <<'EOF'
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
EOF
systemctl enable --now fail2ban
systemctl restart fail2banbackend = systemd matters on Debian 12 and later, which no longer write /var/log/auth.log by default; without it the jail has nothing to read and the service can fail to start.
Verify:
fail2ban-client status sshdMore jails are in Fail2ban: block brute-force attacks.
6. Automatic security updates
# Debian / Ubuntu
apt install unattended-upgrades -y
dpkg-reconfigure -plow unattended-upgrades
# AlmaLinux / Rocky
dnf install dnf-automatic -yOn AlmaLinux and Rocky set these two values in /etc/dnf/automatic.conf, then start the timer:
upgrade_type = security
apply_updates = yessystemctl enable --now dnf-automatic.timerVerify:
unattended-upgrade --dry-run --debug | tail -n 5 # Debian / Ubuntu
systemctl list-timers 'apt-daily*' 'dnf-automatic*' # next run timeNeither tool reboots by default, so a patched kernel waits for you. The longer treatment is in updating a Linux server safely.
7. Hostname and time sync
hostnamectl set-hostname vps1.example.com
timedatectl set-timezone UTC
timedatectlVerify: the timedatectl output must say System clock synchronized: yes. If it says NTP service: n/a, no time client is installed:
apt install systemd-timesyncd -y # Debian / Ubuntu
dnf install chrony -y && systemctl enable --now chronyd # AlmaLinux / Rocky
chronyc tracking # where chrony is in useTip: after changing the hostname, make sure it resolves locally, or every sudo prints "unable to resolve host" and pauses. Add the name to the 127.0.1.1 line in /etc/hosts, or to the 127.0.0.1 line on AlmaLinux and Rocky.
8. Check what is listening
ss -tulpnRead the "Local Address" column. 127.0.0.1 and [::1] are local only. 0.0.0.0, * and [::] are open to every interface, and each of those lines should be something you can name and meant to expose. On a fresh server that normally means sshd on 22 and nothing else. Stop what you do not need:
systemctl disable --now <service>Verify from outside, on your own machine, because that is the view an attacker has:
nmap -Pn 203.0.113.10Outbound matters too. On our Cloud VPS, outbound mail ports 25, 465 and 587 are closed by default and opened on request, so a script that cannot send mail on day one is expected behaviour and not a firewall mistake of yours.
9. Add swap on small servers
With 4 GB of RAM or less, a small swap file turns an out-of-memory kill into a slow minute:
swapon --show # skip this step if something is listed
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf
sysctl --systemVerify:
swapon --show
free -hHow much to add, and how to remove it again, is in adding swap to a VPS.
10. Take a snapshot
The server is now in a known good state, before any application is on it. Take a snapshot or backup from your provider's control panel and note the date. If something goes wrong during the application install, you return to this point instead of repeating the checklist. Where the provider only takes automatic snapshots, wait for the next one before starting risky work.
Verify: the snapshot shows in the panel with today's date. It lives with the same provider as the server, so it is not a backup strategy, but it is the right tool for this moment.
Afterwards
Save the output of steps 7 and 8 somewhere. In three months, when you wonder whether a port was always open, you will have the answer.