On this page
A new server is reachable from the whole internet as soon as it is online, and automated scanners find new addresses quickly. Hardening follows one rule: expose only what you actually serve, and keep it patched. Work through the steps in order, from an SSH session you keep open until every change is tested.
Avoid locking yourself out. Allow SSH before you enable a firewall, test each change from a second terminal, and remember that the IPMI console in your dashboard still works if a rule goes wrong.
Check what is listening
ss -tulpnEach line is an open port. In the Local Address column, 0.0.0.0, [::] or * mean every interface, including the public one; 127.0.0.1 and [::1] mean local only. Stop and disable what you do not need, for example:
systemctl disable --now rpcbind.service rpcbind.socketServices used only by your own applications should listen on localhost: bind-address = 127.0.0.1 for MySQL and MariaDB, listen_addresses = 'localhost' for PostgreSQL, bind 127.0.0.1 -::1 with protected-mode yes for Redis. Never expose databases, Redis, Memcached or Elasticsearch to the internet. If another server needs them, use the free private VLAN between your servers (on request) or a VPN, plus a firewall allow-list.
Set up a firewall
Deny incoming traffic by default and open only the ports you serve. The examples open SSH, HTTP and HTTPS.
Ubuntu and Debian: ufw
apt install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80,443/tcp
ufw enable
ufw status verboseufw writes the matching IPv6 rules as long as IPV6=yes is set in /etc/default/ufw, which is the default. To accept SSH only from your own address, add the specific rule before deleting the general one:
ufw allow from 198.51.100.7 to any port 22 proto tcp
ufw delete allow 22/tcpIf your address changes often, use ufw limit 22/tcp instead: it rejects addresses that open 6 or more connections within 30 seconds.
AlmaLinux and Rocky Linux: firewalld
dnf install -y firewalld
systemctl enable --now firewalld
firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --permanent --remove-service=cockpit
firewall-cmd --reload
firewall-cmd --list-allThe default public zone usually allows ssh, cockpit and dhcpv6-client; skip the cockpit line if you use its web console. Rules without a family apply to IPv4 and IPv6. To accept SSH only from your address (add a second rule with family="ipv6" if you connect over IPv6):
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="198.51.100.7/32" service name="ssh" accept'
firewall-cmd --permanent --remove-service=ssh
firewall-cmd --reloadOn current releases, both ufw and firewalld drive nftables underneath. You can write /etc/nftables.conf by hand instead, but pick one approach: mixing a hand-written ruleset with ufw or firewalld makes the result hard to predict.
Lock down SSH
Log in with keys only. Log in with SSH keys walks you through it and creates /etc/ssh/sshd_config.d/00-keys-only.conf, which turns off password logins. Then add a few limits in a second drop-in file, /etc/ssh/sshd_config.d/10-hardening.conf. Current releases read these files before the main configuration, and the first value read wins:
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding noCheck the syntax, then reload (the service is sshd on AlmaLinux and Rocky Linux):
sshd -t
systemctl reload sshOnce you log in with a sudo user such as deploy, change PermitRootLogin to no in 00-keys-only.conf. Moving SSH to another port reduces log noise, but it is not a security control.
Block brute force with fail2ban
fail2ban reads failed logins from the systemd journal and bans the source address in the firewall for a while. On AlmaLinux and Rocky Linux, enable EPEL first (dnf install -y epel-release) and install the same two packages with dnf:
apt install -y fail2ban python3-systemdCreate /etc/fail2ban/jail.local:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 198.51.100.7
[sshd]
enabled = true
backend = systemdsystemctl enable --now fail2ban
fail2ban-client status sshdOn a public server, Total failed normally starts counting within minutes. If it stays at 0, the filter does not match your logs: with OpenSSH 9.8 or later, failed logins are logged by sshd-session, which older fail2ban filters miss, so update fail2ban. To lift a ban: fail2ban-client set sshd unbanip 198.51.100.7.
Install security updates automatically
Ubuntu normally ships with unattended-upgrades enabled. On Debian, install and enable it:
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgradesBoth values in /etc/apt/apt.conf.d/20auto-upgrades must be "1"; preview a run with unattended-upgrade --dry-run --debug. Kernel updates need a reboot: to reboot automatically at a quiet hour, set Unattended-Upgrade::Automatic-Reboot "true"; and Unattended-Upgrade::Automatic-Reboot-Time "04:00"; in /etc/apt/apt.conf.d/50unattended-upgrades.
On AlmaLinux and Rocky Linux, install dnf-automatic:
dnf install -y dnf-automatic dnf-plugins-coreIn /etc/dnf/automatic.conf, set upgrade_type = security and apply_updates = yes in the [commands] section, then enable the timer. dnf needs-restarting -r tells you when a reboot is needed:
systemctl enable --now dnf-automatic.timer
dnf needs-restarting -rDocker bypasses ufw and firewalld
Docker writes its own iptables rules, and traffic to a published container port is forwarded before your firewall’s input rules see it. A container started with -p 6379:6379 is reachable from the whole internet, whatever ufw says. Two fixes:
Publish on localhost and put a reverse proxy in front of anything public. In Compose, write
"127.0.0.1:6379:6379".docker run -d -p 127.0.0.1:6379:6379 redisFilter in the DOCKER-USER chain, which Docker evaluates before its own rules. Replace
eth0with your public interface (ip route show default):iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 6379 --ctdir ORIGINAL -j DROPThese rules are not saved across reboots: apply them from a systemd unit or your configuration management, and mirror them with
ip6tablesif containers use IPv6.
Never expose the Docker API on TCP port 2375: anyone who reaches it gets root on the host. Manage remote engines over SSH instead, for example DOCKER_HOST=ssh://deploy@server.example.com.
Time sync, audits and console access
- Keep the clock right. TLS, two-factor codes and log correlation depend on it.
timedatectlmust showSystem clock synchronized: yes; if no time service runs, installchrony. Leave out chrony’sallowdirective unless you mean to serve time: an NTP server open to the internet can be abused for amplification attacks. - Audit with Lynis (optional, packaged in Debian, Ubuntu and EPEL).
lynis audit systemprints concrete suggestions: treat them as a checklist, not a score to maximize. - Keep out-of-band access private. Use the IPMI console only through your dashboard, and protect your dashboard account with a unique password.
Next steps
- See what the included network protection covers in How DDoS protection works on your server.
- Finish the basics with the first steps guide.
- Locked out after a firewall change? Use the IPMI console, or contact support, available 24/7.