Server hardening checklist: reduce what attackers can reach
Patching closes known holes; hardening reduces how many holes an attacker can reach in the first place. The work is simple: run fewer network services, keep admin interfaces off the internet, give every account and program only the access it needs, and make sure you can see and undo an attack.
Find what is listening
Every program that accepts network connections is something an attacker can probe; together they make up the server’s attack surface. Start by listing them, as root so you see the owning process:
sudo ss -tulpn
The options mean: -t TCP, -u UDP, -l listening sockets only, -p show the process, -n numbers instead of service names. Read the "Local Address:Port" column:
127.0.0.1:6379or[::1]:5432: loopback only. Reachable from this machine, not from the network.0.0.0.0:3306or[::]:3306or*:3306: every IPv4 or IPv6 address. Reachable from outside unless a firewall blocks it.- A specific address such as
10.0.0.5:9200: only on that interface.
For each line, ask: does this need to exist, and does it need to be reachable from the network? If the answer is no, stop it or bind it to loopback.
# list running services
systemctl list-units --type=service --state=running
# stop a service now and keep it off after reboot (STOPS THE SERVICE)
sudo systemctl disable --now cups.service
# or remove the package if nothing needs it
sudo apt remove cups # Debian, Ubuntu
sudo dnf remove cups # RHEL family, Fedora
Databases and caches used by a local application should listen on loopback only. Defaults differ: PostgreSQL's listen_addresses defaults to localhost, and the stock redis.conf has bind 127.0.0.1 -::1 with protected mode on, but upstream MySQL's bind_address defaults to *, which means all IPv4 and IPv6 interfaces. Check what your package actually set rather than assuming. See ports that should never be public.
Keep admin panels private
Hosting panels, web consoles, database tools such as phpMyAdmin, and monitoring dashboards are high-value targets: one login or one bug gives control of the server. Don't leave them open to the whole internet. In order of preference:
- Reach them only over a VPN or a private network.
- Bind them to
127.0.0.1and use an SSH tunnel:ssh -L 8443:127.0.0.1:8443 admin@server, then openhttps://localhost:8443on your own machine. - If they must face the internet, allow only your office or VPN addresses in the firewall and turn on MFA.
Remove panels and tools you no longer use. An old phpMyAdmin directory that nobody patches is a common way in.
Least privilege
- Named accounts. Least privilege starts with people. Each administrator logs in with their own account and uses
sudo. Shared root passwords make it impossible to tell who did what, or to remove one person's access. - Remove old access. When someone leaves, delete their account and their keys from every
~/.ssh/authorized_keys, including root's. Checksudo -l -U usernameto see what an account may run. - Services as their own users. Daemons should run as dedicated unprivileged users, not root. Redis's own security guide, for example, recommends a dedicated
redisuser. - Application accounts. Give each application its own database user with rights to its own database only, not the database root account.
Separate users per site
If ten websites run as the same Linux user, a hole in one plugin on one site lets the attacker change all ten. Run each site under its own system user (for PHP, a separate PHP-FPM pool per site, each with its own user), with its own home directory and database account. A compromise then stays inside one site. See Updating PHP and nginx, Apache, OpenSSL.
File permissions
- Website code should be owned by a deploy user and only readable by the web server user, not writable by it. Then a web shell cannot rewrite your code.
- Upload and cache directories must be writable, so configure the web server never to execute scripts from them.
- Files holding secrets (database passwords, API keys,
.env,wp-config.php) should be mode640or600, readable by the application's group or user only, and outside the web root where possible. ~/.sshshould be700andauthorized_keys600; sshd refuses keys from files that other users can write.
# world-writable files and directories under the web root
sudo find /var/www -xdev \( -type f -o -type d \) -perm -0002 -ls
# set-uid and set-gid programs on the system (compare against a known-good list)
sudo find / -xdev \( -perm -4000 -o -perm -2000 \) -type f -ls
Never "fix" a permission problem with chmod -R 777. It lets every local user and every compromised service change those files.
Time, logs and backups
Accurate time
Logs from different servers can only be compared if the clocks agree, and TLS and MFA codes fail when the clock drifts. timedatectl shows System clock synchronized: yes when time sync works; sudo timedatectl set-ntp true enables the first available sync service.
Logging
systemd-journald stores logs on disk under /var/log/journal when its Storage= setting is persistent, or auto and that directory exists; otherwise logs live in memory and vanish at reboot. Check that the directory exists. An attacker with root can delete local logs, so also send logs to another machine. See Logging and monitoring.
Offline backups
CISA's ransomware guide says to keep offline, encrypted backups of critical data and to regularly test that they can be restored. "Offline" matters: if the server can delete or overwrite its own backups, so can an attacker or ransomware running as root on it. Use a backup system that the server pushes to but cannot erase (pull-based backups, or storage with retention locks). See Ransomware-proof backups.
MFA for panels and accounts
Passwords leak. CISA recommends phishing-resistant MFA, such as FIDO2 security keys, for all services. Turn on MFA for hosting panels, your cloud or hosting provider account, the domain registrar and DNS, code hosting, and the VPN. The provider account matters as much as the server: whoever controls it can open a console, reset root or delete the machine. For SSH, see MFA options for SSH.
Automatic security updates
Most attacks use known bugs that already have fixes. Let the server install security updates by itself and keep manual patching for the rest. The short version:
# Debian, Ubuntu
sudo apt install unattended-upgrades
sudo dpkg-reconfigure unattended-upgrades
# RHEL family: set upgrade_type = security in /etc/dnf/automatic.conf, then
sudo dnf install dnf-automatic
sudo systemctl enable --now dnf-automatic-install.timer
Installed updates still need service restarts and, for kernels, a reboot. Details are in Automatic updates and Restarts and reboots.
The checklist
ss -tulpnshows only services you need, and internal ones listen on loopback.- A default-deny firewall covers IPv4 and IPv6 (Firewall basics).
- SSH uses keys only, with root password login off (SSH hardening).
- Admin panels are reachable only over VPN, SSH tunnel or an IP allowlist, with MFA.
- Every admin has a named account; no ex-staff accounts or keys remain.
- Each site runs as its own user; code is not writable by the web server.
- No world-writable files under the web root; secrets are
600/640. - Clock synchronized; logs persistent and copied off the server.
- Backups are offline or immutable, and a restore was tested recently.
- Automatic security updates on; restarts and reboots happen on schedule.
Sources
- ss(8) manual page, man7.org
- Connections and authentication, PostgreSQL documentation
- Server system variables (bind_address), MySQL 8.4 Reference Manual
- Default redis.conf, Redis project
- Redis security, Redis documentation
- timedatectl(1) manual page, man7.org
- journald.conf(5) manual page, man7.org
- #StopRansomware Guide, CISA
- SP 800-123, Guide to General Server Security, NIST
- UnattendedUpgrades, Debian Wiki
- DNF Automatic, DNF documentation