Harden your server step by step

SSH: keys only

The first and largest win on any Linux server is to stop accepting passwords over SSH. Bots try them all day; a key cannot be guessed. Open a separate configuration file rather than editing the main one, so that a package update cannot overwrite your settings:

sudo vi /etc/ssh/sshd_config.d/00-hardening.conf

PermitRootLogin no PasswordAuthentication no KbdInteractiveAuthentication no PubkeyAuthentication yes AllowUsers example

The file starts with 00- on purpose. sshd reads sshd_config.d/*.conf in name order and keeps the first value it reads for each keyword, and many cloud images ship 50-cloud-init.conf with PasswordAuthentication yes, which would win over a later file.

PermitRootLogin no closes the root account over SSH; PasswordAuthentication no accepts keys only; AllowUsers lists the accounts that may log in at all. Check the file before you apply it, because a syntax error here can lock you out:

sudo sshd -t

No output means the file is valid. Check the values sshd really uses, after all the included files are read:

sudo sshd -T | grep -E "^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication) "

permitrootlogin no passwordauthentication no kbdinteractiveauthentication no

Keep your current session open and apply the change from a second terminal, so that a mistake leaves you a way back:

sudo systemctl restart ssh

Then try to open a new session. If it works, close the old one. If it does not, the old session still has root or sudo access, and the provider console is the last resort. The rules of SSH itself are in SSH in Linux.

fail2ban

Keys stop the bots from getting in, but they still fill the log with attempts. fail2ban reads the log and blocks the address after a few failures:

sudo apt install fail2ban

sudo vi /etc/fail2ban/jail.local

[sshd] enabled = true maxretry = 5 findtime = 10m bantime = 1h

sudo systemctl enable --now fail2ban

sudo fail2ban-client status sshd

Status for the jail: sshd |- Filter | |- Currently failed: 2 | |- Total failed: 38 | `- Journal matches: _SYSTEMD_UNIT=ssh.service + _COMM=sshd `- Actions |- Currently banned: 1 |- Total banned: 1 `- Banned IP list: 198.51.100.77

Five failures in ten minutes earn an hour of silence. The ban lives in the firewall, so it disappears on reboot unless fail2ban starts again; enable takes care of that. Never ban your own address while you test: add it to ignoreip in the same file.

The firewall

The default policy should reject everything incoming that no rule allows. On Ubuntu ufw makes that three commands, in this order:

sudo ufw default deny incoming

sudo ufw allow OpenSSH

sudo ufw allow "Nginx Full"

sudo ufw enable

Add a rule before you open a new service, not after. When mail arrives in lesson 9, the mail ports are added the same way. A firewall is not a substitute for a service that listens only where it should: both layers should agree. Firewall Linux explains the rules and how to read them.

Automatic security updates

Most successful attacks use a hole that was fixed months earlier. Ubuntu can install security updates by itself, which is the single most effective setting on a small server:

sudo apt install unattended-upgrades

sudo vi /etc/apt/apt.conf.d/20auto-upgrades

APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1";

sudo systemctl list-timers apt-daily-upgrade.timer

NEXT LEFT LAST PASSED UNIT ACTIVATES Fri 2026-11-06 18:00:00 UTC 3h left Fri 2026-11-06 06:00:00 UTC 9h ago apt-daily-upgrade.timer apt-daily-upgrade.service

The timer runs the installer twice a day. See what it would install right now, without changing anything:

sudo unattended-upgrade --dry-run --debug

Starting unattended upgrades script Allowed origins are: o=Ubuntu,a=noble,o=Ubuntu,a=noble-security,... Packages that will be upgraded:

The last line lists the packages it would install; an empty list means the server is already current. Do not check unattended-upgrades.service for this: that unit is only the shutdown helper, not the job that installs the updates. By default only the security pocket is installed automatically. A kernel update needs a reboot to take effect, and the server will not reboot itself: check /var/run/reboot-required and plan the reboot. Updates: patch day, not someday has the wider habit.

The site's code and its libraries

unattended-upgrades patches Ubuntu packages, not the code inside the site folder. That code needs its own discipline. Keep it in git and deploy from the repository, so that every change can be reviewed and rolled back. If the site uses Composer, it has libraries of its own; run these two commands as the site user in the project folder, then update and test:

sudo -u example -H composer --working-dir=/var/www/example.com audit

sudo -u example -H composer --working-dir=/var/www/example.com outdated

audit lists installed packages with known vulnerabilities; outdated lists the ones with newer versions. Update the ones you need, run the site tests and deploy. Follow the security announcements of the framework, the CMS or the libraries you use, because a release note is faster than a scanner. Delete the code and libraries the site no longer needs: unused code is still reachable code.

If your site is WordPress, the side story lists the extra update steps: Side story: WordPress (optional).

Only the services you need

Every listening service is a door. List them, and for each one answer three questions: do I need it, who may reach it, and is it updated?

sudo ss -tlnp

LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=812,fd=6)) LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=812,fd=7)) LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=701,fd=3)) LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mariadbd",pid=944,fd=21))

This is a healthy list: two web ports for the world, SSH for you, and a database that only the machine itself can reach. Anything else is a candidate for removal:

sudo systemctl disable --now apache2

The command above is the usual example: a server that has both Nginx and Apache installed, with Apache still holding port 80 on a fresh boot. Do not remove a package you are not sure about; disable the service first, reboot, and watch that nothing breaks.

The database on localhost

MariaDB should accept connections from the machine it runs on and from nowhere else. Check the setting and then the listening address:

sudo vi /etc/mysql/mariadb.conf.d/50-server.cnf

bind-address = 127.0.0.1

sudo systemctl restart mariadb

sudo ss -tlnp | grep 3306

LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mariadbd",pid=944,fd=21))

A database user is scoped to a host as well as a name: 'example_user'@'localhost' and 'example_user'@'%' are different accounts. Create only the local one. If a tool asks for remote access, use an SSH tunnel instead of opening the port:

ssh -L 3307:127.0.0.1:3306 example@203.0.113.10

The tunnel makes the remote database appear on your own machine as 127.0.0.1:3307, encrypted and authenticated by SSH. Nothing new listens on the server.

One user and one pool per site

The default PHP pool runs every site as the same user, which is the user of the web server, www-data. Give each site its own system user and its own pool, so that one broken site cannot read the files of another. The site from lesson 5 already belongs to the user example, so the pool names it directly; a second site gets its own system user, created like this:

sudo adduser --system --group --home /var/www/shop.example.net shop

The pool of the first site, then:

sudo vi /etc/php/8.3/fpm/pool.d/example.com.conf

[example.com] user = example group = example listen = /run/php/php8.3-fpm-example.sock listen.owner = www-data listen.group = www-data pm = dynamic pm.max_children = 10 pm.start_servers = 2 pm.min_spare_servers = 1 pm.max_spare_servers = 3 chdir = /var/www/example.com php_admin_value[open_basedir] = /var/www/example.com:/tmp php_admin_value[disable_functions] = exec,passthru,shell_exec,system

sudo systemctl reload php8.3-fpm

Then point the server block at the new socket, in place of the default one:

sudo vi /etc/nginx/sites-available/example.com

location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm-example.sock; }

open_basedir limits the folders PHP may open, and the disabled functions remove the easiest ways to run a shell command. Some plugins need one of them; remove only the ones your site proves it needs, and write down why.

File permissions

The site user owns the files, and the web server group may read them so that Nginx can serve static files. Nobody else needs anything:

sudo chown -R example:www-data /var/www/example.com

sudo find /var/www/example.com -type d -exec chmod 750 {} \;

sudo find /var/www/example.com -type f -exec chmod 640 {} \;

Folders need the execute bit to be entered; files do not. Configuration files with passwords are stricter still:

sudo chmod 600 /var/www/example.com/config.php

600 means only the owner reads it. Since PHP runs as that owner, the site keeps working, and another user on the machine sees nothing. The pool's open_basedir is /var/www/example.com:/tmp, so PHP can still open the file from html/index.php. The same rule applies to a .env file and to a private key.

TLS and security headers

Certbot already wrote a working TLS configuration, and the file it includes, options-ssl-nginx.conf, already sets the modern protocols and the session settings:

grep -E "ssl_protocols|ssl_session" /etc/letsencrypt/options-ssl-nginx.conf

ssl_session_cache shared:le_nginx_SSL:10m; ssl_session_timeout 1440m; ssl_session_tickets off; ssl_protocols TLSv1.2 TLSv1.3;

Do not repeat those directives in the site configuration: a second ssl_prefer_server_ciphers or ssl_session_timeout makes nginx -t fail with a duplicate directive error. What the include does not add are the headers a browser uses to protect visitors. Put these four lines inside the server block with listen 443 ssl that certbot wrote:

sudo vi /etc/nginx/sites-available/example.com

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header X-Frame-Options "SAMEORIGIN" always;

An add_header inside a location block replaces the server-level headers for that location, so a location that needs one of them must repeat all four that it wants.

HSTS tells the browser to use HTTPS for this name for a year, and to refuse a plain HTTP connection even if a link asks for one. Switch it on only when HTTPS works for every subdomain you have, because it is hard to undo. The other three headers stop a browser from guessing a file type, from leaking the full address of the previous page, and from putting your site in a frame on another site. The current recommendations are at ssl-config.mozilla.org.

sudo nginx -t

sudo systemctl reload nginx

curl -sI https://example.com | grep -i strict-transport

strict-transport-security: max-age=31536000; includeSubDomains

Hide versions, add rate limits

A response that says nginx/1.24.0 (Ubuntu) tells an attacker which exploits to try first. Hide it in the http block, and hide the PHP version in its configuration:

sudo vi /etc/nginx/nginx.conf

server_tokens off;

sudo vi /etc/php/8.3/fpm/php.ini

expose_php = Off

sudo systemctl reload php8.3-fpm

Hiding a version is not a defence by itself, but it removes free information. The real defence for a small flood is a rate limit, which counts requests per address. Put it on the requests that run PHP, not on the whole site: a normal page often loads dozens of CSS, JavaScript and image files, and a limit on all of them would refuse normal visitors. Define the zone in the http block:

sudo vi /etc/nginx/nginx.conf

limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;

Then use it inside the PHP location of the site, and, if your site has a login page, add a separate and tighter limit for it. Answer refusals with 429 instead of the default 503:

sudo vi /etc/nginx/sites-available/example.com

location ~ \.php$ { limit_req zone=perip burst=20 nodelay; limit_req_status 429; include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm-example.sock; } location = /login.php { limit_req zone=perip burst=5 nodelay; limit_req_status 429; include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm-example.sock; }

Ten requests per second from one address, with a burst of twenty, is generous for a person and tight for a bot. Watch the error log for refusals; if real visitors appear in it, raise the numbers:

sudo grep "limiting requests" /var/log/nginx/error.log

2026/11/06 10:00:00 [error] 1234#1234: *56 limiting requests, excess: 20.500 by zone "perip", client: 203.0.113.55, server: example.com, request: "POST /login.php HTTP/1.1"

A cache in front of the site, or a provider DDoS service, handles what the limit cannot.

Registrar, provider and panel accounts

The server is only one of the accounts that control the site. The other three are the registrar, the hosting provider and the control panel, and each of them can lose the whole site in one click.

Two-factor authentication everywhere explains the options and why an authenticator app or a hardware key beats an SMS code. Password managers explains how to keep a different password for each of the four accounts without remembering any of them.

3-2-1 backups with a restore test

Three copies, on two kinds of storage, one of them off the server. A simple version that fits a small site: a nightly database dump kept on the server, files and dumps pushed to another machine, and a monthly copy on storage the server cannot delete.

Put the commands in a script that root runs. A redirection like sudo mysqldump ... > /var/backups/... is performed by your own user, not by root, so writing into a root-only folder fails with Permission denied. Inside the script every command is already root:

sudo vi /usr/local/sbin/backup-site

#!/bin/sh set -e mkdir -p /var/backups/site mysqldump --single-transaction example_db | gzip > /var/backups/site/db-$(date +%F).sql.gz rsync -a --delete /var/www/example.com/ /var/backups/site/files/ find /var/backups/site -name 'db-*.sql.gz' -mtime +14 -delete rsync -az /var/backups/site/ backup@198.51.100.30:/srv/backup/example.com/

sudo chmod 700 /usr/local/sbin/backup-site

The last line copies the folder to another machine and needs an SSH key for backup@198.51.100.30 in /root/.ssh, because the script runs as root. Run it once by hand, then schedule it for 03:00 every night with root cron:

sudo /usr/local/sbin/backup-site

sudo crontab -e

0 3 * * * /usr/local/sbin/backup-site

Then prove that the backup is real, into a separate place, not over the live data:

sudo mariadb -e "CREATE DATABASE restore_test;"

gunzip -c /var/backups/site/db-2026-11-06.sql.gz | sudo mariadb restore_test

sudo mariadb -e "SELECT COUNT(*) FROM restore_test.pages;"

+----------+ | COUNT(*) | +----------+ | 24 | +----------+

sudo mariadb -e "DROP DATABASE restore_test;"

A row count that looks like the real site means the dump is readable and complete. Drop the test database afterwards, as the last command does. For encrypted, deduplicated copies to an object store, restic and borg are the usual tools; the method is in Backups that actually restore.

Monitoring and logs

Hardening is not finished until something tells you when it stops working. Four checks cover most of it: is the site up, is the disk filling, is the certificate young enough, is the newest backup recent. An external uptime checker, free or paid, does the first; a short script does the rest.

df -h /

sudo certbot certificates | grep -E "Certificate Name|Expiry"

Certificate Name: example.com Expiry Date: 2027-02-04 09:00:00+00:00 (VALID: 89 days)

The authentication log is where a break-in shows first. Failed logins, a new user created at 4 a.m., a successful login from a country you have never visited:

sudo tail -n 10 /var/log/auth.log

2026-11-06T10:00:00.123456+00:00 example sshd[1234]: Failed password for invalid user admin from 198.51.100.77 port 51234 ssh2 2026-11-06T10:00:01.234567+00:00 example sshd[1236]: Accepted publickey for example from 203.0.113.55 port 51240 ssh2: ED25519 SHA256:...

sudo journalctl -u ssh --since "today" --no-pager | grep -c "Failed password"

0

A count of zero is the healthy answer once passwords are off. Then check that no file changed under the system directories without a package update: debsums compares every installed file with the checksum recorded when it was installed. The -a option adds the configuration files, which -c alone skips:

sudo apt install debsums

sudo debsums -ca

Empty output is clean. A modified file in /usr/bin or /etc is a serious signal, and the next section is the response. Remember that an attacker who already has root can change the stored checksums as well, so this is a quick check; AIDE, which keeps its own database, is the stronger tool.

After a break-in

You will not feel calm, so follow a list. The order matters more than speed, because a wrong step destroys the evidence you need to understand what happened.

  1. Isolate. Take a snapshot or an image of the disk, then remove the machine from the network, or block all traffic except your own address. Do not reboot yet: memory holds evidence.
  2. Preserve. Copy the logs, the process list, the network connections and the list of scheduled jobs somewhere outside the machine. Note the time you noticed, in UTC.
  3. Assume every secret is lost. Change the passwords and keys of the site, the database, the mail, the panel, the registrar and the provider. Revoke API tokens and SSH keys.
  4. Rebuild from a clean image. Restoring the same system from its own backup restores the door the attacker used. Install fresh, then restore only the site files and the database, after checking them.
  5. Find the way in. Compare the site files with the repository, read the access log around the first strange request, check for new systemd units, cron jobs, SSH keys and database users.
  6. Tell the people who must know. The provider, if the machine attacks others; the customers or users, if their data was exposed; the authority, if the law of your country requires it.
  7. Write the timeline. What happened, in what order, what you changed. It turns one bad week into a lesson the whole team can use.

The most common way back in is the hole that was never found. If you cannot explain how the attacker entered, do not trust the rebuilt server with the same application until you have updated it and read its logs from the first minute.

Quiz

1 Which two settings turn off password logins for SSH?

  1. PasswordAuthentication no and KbdInteractiveAuthentication no
  2. PermitRootLogin no and AllowUsers example
  3. PubkeyAuthentication yes and UsePAM yes
  4. Port 22 and ListenAddress
Answer

a) PasswordAuthentication no closes the password method, and KbdInteractiveAuthentication no closes the challenge-response path that can ask for the same password. PermitRootLogin and AllowUsers limit who may log in, but a password still works for an allowed account.

2 Why allow SSH in the firewall before enabling it?

  1. Because ufw needs a restart
  2. Because the default policy denies incoming traffic, including your own session
  3. Because Nginx needs port 22
  4. Because fail2ban requires it
Answer

b) Enabling a deny-by-default firewall without an SSH rule closes the connection you are using. Allow first, then enable.

3 What does unattended-upgrades install by default?

  1. Every package, including new major versions
  2. Security updates from the security pocket
  3. Only kernel updates
  4. Nothing, it only downloads
Answer

b) The security pocket is the default. Kernel updates are installed but need a reboot, which the server does not do by itself.

4 A pool per site sets open_basedir. What does it do?

  1. Limits the folders PHP may open
  2. Limits the number of requests
  3. Encrypts the site files
  4. Blocks SQL injection
Answer

a) It is a filesystem boundary for PHP, so a bug in one site cannot read another site or a system file. It is not a limit on traffic and not a filter for queries.

5 Which permission mode fits a file that contains a database password and is read by PHP only?

  1. 777
  2. 644
  3. 600
  4. 755
Answer

c) 600 means only the owner reads and writes. PHP runs as that owner, so the site works and nobody else reads the secret.

6 What does HSTS tell the browser?

  1. To cache images longer
  2. To use HTTPS for this name for the given time, even if a link says HTTP
  3. To block cookies
  4. To check the certificate every hour
Answer

b) The browser remembers the header and refuses plain HTTP to that name. It is powerful and hard to undo, so switch it on only when every subdomain has HTTPS.

7 Why restore a backup into a separate database before trusting it?

  1. Because the dump is encrypted
  2. Because a backup is only proven by a successful restore, and the live database must not be overwritten by a test
  3. Because MariaDB refuses to import twice
  4. Because the dump has no tables
Answer

b) The test proves the file is complete and readable without risking the site. A backup nobody restored is not proof.

8 After a break-in, why rebuild from a clean image instead of restoring the server backup?

  1. Because backups are slower
  2. Because the backup contains whatever let the attacker in
  3. Because the certificate is invalid
  4. Because the provider requires it
Answer

b) A system backup copies the compromise too, including modified binaries, keys and scheduled jobs. Rebuild the system and restore only the data you checked.

Article author: Arthur Isaev