Mert Özen Further With Every Line
DevOps Linux and the Shell (for Developers) — #11

Hardening a Server: SSH Settings, ufw, Fail2ban, Automatic Updates and Least Privilege

Mert Özen Nov 14, 2026 16 min 4 views
Hardening a Server: SSH Settings, ufw, Fail2ban, Automatic Updates and Least Privilege

To turn off password logins on Ubuntu, put the setting in a drop-in under /etc/ssh/sshd_config.d/ that is read before cloud-init's file and verify it with sshd -T, because sshd uses the first value it reads. On the Bookstore server we show the second session rule, enabling ufw in the right order, Docker bypassing ufw for published ports, Fail2ban and least privilege with systemd, all with real output.

Version note: The commands in this post were run on a test VM running Ubuntu Server 24.04.5 LTS (kernel 6.8, systemd 255, OpenSSH 9.6p1, ufw 0.36.2, iptables 1.8.10 nf_tables, Fail2Ban 1.0.2, Docker 29.1.3 from the Ubuntu archive) and on an Ubuntu 24.04 client on the same network; Bookstore.Api was run on the ASP.NET Core 10.0.15 runtime.

Guess First

You want to turn off password logins on the server of Bookstore, the online bookstore used as the shared example across this site's series. You uncomment the setting in /etc/ssh/sshd_config, test the config and reload SSH:

$ sudo sed -i 's/^#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
$ grep -n "^PasswordAuthentication" /etc/ssh/sshd_config
66:PasswordAuthentication no
$ sudo sshd -t && sudo systemctl reload ssh

Then, from your own machine, you deliberately disable your key and try to log in with a password:

ada@my-pc:~$ ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password ada@bookstore-dev
ada@10.20.30.10's password:
Welcome to Ubuntu 24.04.5 LTS (GNU/Linux 6.8.0-149-generic x86_64)
...
ada@bookstore-dev:~$

It asked for a password and the login worked. The setting is in the file, sshd -t found no error and the reload went through. Why did it not take effect? The answer is in the next section.

In this post we apply the basic hardening steps for an internet-facing Linux server to the Bookstore server: SSH settings, a firewall with ufw, how Docker bypasses that firewall, Fail2ban, automatic updates and least privilege. The goal is defensive: know which doors are open, close the ones you do not need, and do it without locking yourself out of the server.

sshd_config.d: The First Value Read Wins

$ grep -n "^Include" /etc/ssh/sshd_config
12:Include /etc/ssh/sshd_config.d/*.conf
$ ls -l /etc/ssh/sshd_config.d/
-rw------- 1 root root 27 Nov 14 14:05 50-cloud-init.conf
-rw-r--r-- 1 root root 26 Oct 17 12:57 60-cloudimg-settings.conf
$ sudo head /etc/ssh/sshd_config.d/*.conf
==> /etc/ssh/sshd_config.d/50-cloud-init.conf <==
PasswordAuthentication yes

==> /etc/ssh/sshd_config.d/60-cloudimg-settings.conf <==
PasswordAuthentication no
$ sudo sshd -T | grep "^passwordauthentication"
passwordauthentication yes

The answer to the guess is two rules combined. First, according to the sshd_config(5) man page, sshd uses the first value it reads for a setting and ignores the same setting later. Second, Ubuntu's sshd_config includes the files under sshd_config.d/ on line 12, so the drop-ins are read before the settings in the main file. cloud-init, which set up this VM, wrote PasswordAuthentication yes into 50-cloud-init.conf because of the ssh_pwauth setting from the first post. That file is read before both the image's own 60-cloudimg-settings.conf and our line 66, and it wins. That is why reading the file and saying "the setting is right" is misleading; sshd -T tells you what sshd actually uses.

The right place is our own drop-in whose name sorts before cloud-init's file. I put the main file back the way the package left it and wrote 10-bookstore.conf (in the repo as deploy/linux/ssh/sshd_config.d/10-bookstore.conf):

# Bookstore server: key-only login, no root, only these users
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowUsers ada deploy
MaxAuthTries 3
X11Forwarding no

KbdInteractiveAuthentication is the second way of asking for a password through PAM; if you leave it on, some setups ask for the password through that door. The OpenSSH default for PermitRootLogin is prohibit-password, meaning root can log in with a key; sshd -T shows it under its old name without-password, as in the first post. We never log in as root over SSH: everyone logs in with their own account and uses sudo. AllowUsers rejects every user not on the list, even with a valid key: ada is the admin account and deploy is the CI user from the SSH deploy post of the GitHub Actions series. Always run sshd -t before you reload:

$ echo "PermitRootLogin noo" | sudo tee /etc/ssh/sshd_config.d/99-typo.conf >/dev/null
$ sudo sshd -t; echo "exit=$?"
/etc/ssh/sshd_config.d/99-typo.conf line 1: unsupported option "noo".
exit=255
$ sudo rm /etc/ssh/sshd_config.d/99-typo.conf
$ sudo sshd -T | grep -E "^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin|allowusers|maxauthtries|x11forwarding) "
maxauthtries 3
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
x11forwarding no
allowusers ada
allowusers deploy
ada@my-pc:~$ ssh -o PubkeyAuthentication=no ada@bookstore-dev true; echo "exit=$?"
ada@10.20.30.10: Permission denied (publickey).
exit=255
ada@my-pc:~$ ssh -o BatchMode=yes root@bookstore-dev true; echo "exit=$?"
root@10.20.30.10: Permission denied (publickey).
exit=255

The server no longer even offers the password method; the error message lists only publickey. sshd -t catches typos but not logic errors: forgetting to put yourself in AllowUsers is a valid config. What catches that is the rule in the next section.

The Second Session Rule

When you change SSH settings, do not close your current session; test the change with a separate, new connection. The rule relies on how systemctl reload ssh works: as we saw in the process tree in the fourth post, every session has its own sshd process, and a reload only makes the listening main process read the config again, so open sessions carry on as before. I wrote the rule wrong on purpose: while adding deploy, I dropped myself from the list.

# session A: stays open
ada@bookstore-dev:~$ sudo install -m 644 /tmp/10-bookstore.bad /etc/ssh/sshd_config.d/10-bookstore.conf
ada@bookstore-dev:~$ sudo sshd -t && sudo systemctl reload ssh && echo reloaded
reloaded
ada@bookstore-dev:~$ sudo sshd -T | grep -E '^(passwordauthentication|permitrootlogin|allowusers) '
permitrootlogin no
passwordauthentication no
allowusers deploy

# session B: a new terminal
ada@my-pc:~$ ssh -o BatchMode=yes bookstore-dev "echo second session ok"
ada@10.20.30.10: Permission denied (publickey).

# session A: still logged in
ada@bookstore-dev:~$ sudo journalctl _COMM=sshd -n 2 --no-pager -o short-iso
2026-11-14T17:21:31+00:00 bookstore-dev sshd[1718]: User ada from 10.20.30.5 not allowed because not listed in AllowUsers
2026-11-14T17:21:31+00:00 bookstore-dev sshd[1718]: Connection closed by invalid user ada 10.20.30.5 port 50338 [preauth]
ada@bookstore-dev:~$ sudo install -m 644 /tmp/10-bookstore.good /etc/ssh/sshd_config.d/10-bookstore.conf && sudo sshd -t && sudo systemctl reload ssh && echo reloaded
reloaded

# session C: a new terminal
ada@my-pc:~$ ssh -o BatchMode=yes bookstore-dev "echo second session ok"
second session ok

sshd -t said nothing, because the file was valid. The new connection was rejected and the reason on the server side is clear: "not listed in AllowUsers". The message on the client, however, is the same as for a wrong key, Permission denied (publickey), so from the outside you cannot tell what broke. Had I closed session A, the only way back in would have been the cloud provider's web console or a rescue mode. The order is always the same: sshd -t, reload, a new connection from a second terminal, and only when that succeeds close the first session.

On Ubuntu 24.04 SSH runs with socket activation (first post): systemd's ssh.socket listens on port 22. For that reason, according to Ubuntu's documentation, a change to Port or ListenAddress takes effect after a daemon-reload and a restart of ssh.socket, not after a reload. I did not change the port in this post; moving away from 22 reduces automated attempts in the log but is not security on its own.

ufw: Allow SSH First, Then Enable

ufw was part of a fault in the eighth post and was reset at the end of it. Now we set it up from scratch. Again I got the order wrong on purpose: I enabled the firewall before allowing SSH.

# session A
ada@bookstore-dev:~$ sudo ufw default deny incoming
Default incoming policy changed to 'deny'
(be sure to update your rules accordingly)
ada@bookstore-dev:~$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup

# session B: a new terminal
ada@my-pc:~$ ssh -o BatchMode=yes -o ConnectTimeout=10 bookstore-dev "echo second session ok"
ssh: connect to host 10.20.30.10 port 22: Connection timed out

# session A: still logged in
ada@bookstore-dev:~$ sudo ufw allow OpenSSH
Rule added
Rule added (v6)
ada@bookstore-dev:~$ sudo ufw app info 'Nginx Full' | tail -n 4
Description: Small, but very powerful and efficient web server
Ports:
  80,443/tcp
ada@bookstore-dev:~$ sudo ufw allow 'Nginx Full'
Rule added
Rule added (v6)

# session C: a new terminal
ada@my-pc:~$ ssh -o BatchMode=yes -o ConnectTimeout=10 bookstore-dev "echo second session ok"
second session ok

ufw warned, and the warning was right. The open session survived because ufw's built-in rules accept established connections, the ones in the ESTABLISHED state in connection tracking; the new connection was dropped silently and the client came back with a timeout. Remember this difference from the eighth post: a packet dropped by a firewall gives a timeout, not "Connection refused". The right order is default deny incoming, allow OpenSSH, the other ports you need, and enable last. OpenSSH and Nginx Full are application profiles that packages drop into /etc/ufw/applications.d/; using them instead of port numbers makes the rules readable.

$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
80,443/tcp (Nginx Full)    ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
80,443/tcp (Nginx Full (v6)) ALLOW IN    Anywhere (v6)
$ sudo ufw show listening
tcp:
  22 * (sshd)
   [ 1] allow OpenSSH

  80 * (nginx)
   [ 2] allow 'Nginx Full'

tcp6:
  22 * (sshd)
   [ 3] allow OpenSSH

udp:
$ sudo iptables -V
iptables v1.8.10 (nf_tables)
$ sudo nft list chain ip filter ufw-user-input
# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
	chain ufw-user-input {
		tcp dport 22  counter packets 3 bytes 180 accept
		ip protocol tcp tcp dport { 80, 443 }  counter packets 0 bytes 0 accept
	}
}

ufw show listening puts the inventory of open ports next to the rules: every port listening on the outside is shown with the rule that allows it. Bookstore.Api is not here because, as we saw in the eighth post, it only listens on 127.0.0.1:5000; it is unreachable from the outside even without a firewall. ufw does not filter packets itself: it turns its rules into iptables commands. On Ubuntu 24.04 iptables runs on the nf_tables backend, so the rules end up in the kernel's nftables framework. counter packets 3 in the nft output shows the port 22 rule accepted three packets. nftables' own syntax and the move away from iptables are the topic of the last post; while you use ufw, do not edit those tables by hand, as the warning says ufw manages them.

Docker and ufw: Published Ports Bypass the Firewall

If Docker also runs on the server, part of your ufw rules does not apply. I installed the docker.io package from the Ubuntu archive and published an Nginx container on 8080; ufw has no rule at all for 8080:

$ sudo docker run -d --name bookstore-web-preview -p 8080:80 nginx:alpine
$ sudo ufw status | grep -E "8080|Status"
Status: active
$ sudo ss -tlnp | grep 8080
LISTEN 0      4096         0.0.0.0:8080       0.0.0.0:*    users:(("docker-proxy",pid=3664,fd=7))
LISTEN 0      4096            [::]:8080          [::]:*    users:(("docker-proxy",pid=3670,fd=7))
$ sudo iptables -t nat -S DOCKER | grep 8080
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
ada@my-pc:~$ curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://10.20.30.10:8080/
200

The firewall says "deny incoming", yet the client got a 200. Docker's documentation states this plainly: a packet for a published port is redirected to the container's address with a DNAT rule in the nat table. The packet is no longer addressed to this machine but to the container, so the kernel passes it through the FORWARD chain, not the INPUT chain where ufw's rules live, and Docker's own rules allow it there. For a port you do not want to expose, the fix is to bind the container to loopback only:

$ sudo docker rm -f bookstore-web-preview
$ sudo docker run -d --name bookstore-web-preview -p 127.0.0.1:8080:80 nginx:alpine
$ sudo ss -tlnp | grep 8080
LISTEN 0      4096       127.0.0.1:8080       0.0.0.0:*    users:(("docker-proxy",pid=3959,fd=7))
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/
200
ada@my-pc:~$ curl -sS -o /dev/null --max-time 5 http://10.20.30.10:8080/
curl: (28) Connection timed out after 5047 milliseconds

It is reachable from inside the server, and Nginx can expose it with proxy_pass; from the outside it is not. Docker's documentation notes that before version 28 machines on the same L2 network could also reach ports published to loopback; the version in the Ubuntu archive is 29, so that is not an issue on this server. Docker networking, the DOCKER-USER chain and the daemon's default bind address are covered in the network post of the Docker series. After the test I removed the container and Docker from the server; in this series Bookstore runs under systemd.

Fail2ban: Blocking Repeated Attempts

With password logins off, a brute-force attack cannot succeed, but it still fills the log and every attempt costs an sshd process. Fail2ban reads the log and, when it sees many failed attempts from the same address, blocks that address in the firewall for a while.

$ sudo apt install fail2ban
$ cat /etc/fail2ban/jail.d/defaults-debian.conf
[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]
backend = systemd

[sshd]
enabled = true
$ grep -E "^(bantime|findtime|maxretry) " /etc/fail2ban/jail.conf
bantime  = 10m
findtime  = 10m
maxretry = 5

The Ubuntu package enables the sshd jail as soon as it is installed, reads the log from journald and blocks with nftables. The defaults: 5 failures in 10 minutes means a 10-minute ban. Do not edit jail.conf, as a package update overwrites it; put your settings in a .local file under jail.d/ (in the repo as deploy/linux/fail2ban/jail.d/bookstore.local):

[DEFAULT]
# the management machine (my-pc) is never banned
ignoreip = 127.0.0.1/8 ::1 10.20.30.5

[sshd]
# 3 failures in 10 minutes = banned for 1 hour
maxretry = 3
findtime = 10m
bantime = 1h
$ sudo fail2ban-client -t
OK: configuration test is successful
$ sudo systemctl reload fail2ban
$ sudo fail2ban-client get sshd ignoreip
These IP addresses/networks are ignored:
|- 127.0.0.0/8
|- 10.20.30.5
`- ::1

To play the attacker, I added a second address (10.20.30.66) to the client and tried to connect from it with users that do not exist:

ada@my-pc:~$ for u in admin oracle test ubuntu; do ssh -b 10.20.30.66 -o BatchMode=yes -o ConnectTimeout=5 $u@10.20.30.10 true; echo "$u exit=$?"; sleep 2; done
admin@10.20.30.10: Permission denied (publickey).
admin exit=255
oracle@10.20.30.10: Permission denied (publickey).
oracle exit=255
test@10.20.30.10: Permission denied (publickey).
test exit=255
ssh: connect to host 10.20.30.10 port 22: Connection refused
ubuntu exit=255
$ sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed:	0
|  |- Total failed:	5
|  `- Journal matches:	_SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
   |- Currently banned:	1
   |- Total banned:	1
   `- Banned IP list:	10.20.30.66
$ sudo grep -E "Found|Ban" /var/log/fail2ban.log | tail -n 5 | cut -c1-120
2026-11-14 17:30:38,459 fail2ban.filter         [5037]: INFO    [sshd] Found 10.20.30.5 - 2026-11-14 17:21:45
2026-11-14 17:31:46,697 fail2ban.filter         [5037]: INFO    [sshd] Found 10.20.30.66 - 2026-11-14 17:31:46
2026-11-14 17:31:48,921 fail2ban.filter         [5037]: INFO    [sshd] Found 10.20.30.66 - 2026-11-14 17:31:48
2026-11-14 17:31:51,148 fail2ban.filter         [5037]: INFO    [sshd] Found 10.20.30.66 - 2026-11-14 17:31:50
2026-11-14 17:31:51,412 fail2ban.actions        [5037]: NOTICE  [sshd] Ban 10.20.30.66
$ sudo nft list table inet f2b-table
table inet f2b-table {
	set addr-set-sshd {
		type ipv4_addr
		elements = { 10.20.30.66 }
	}

	chain f2b-chain {
		type filter hook input priority filter - 1; policy accept;
		tcp dport 22 ip saddr @addr-set-sshd reject with icmp port-unreachable
	}
}

After three attempts 10.20.30.66 was banned and the fourth attempt got "Connection refused": Fail2ban's nftables rule does not drop the packet, it rejects it. The rule is not in ufw's table but in Fail2ban's own f2b-table, and it runs before it (priority filter - 1). Note the first line of the log: a connection of mine from 10.20.30.5 that was rejected in the tests above also counted as a failure, but it was not banned because it is in ignoreip. Think of that scenario before you leave ignoreip out: an admin who makes a config mistake and retries three times locks out their own address for an hour. To lift a ban by hand:

$ sudo fail2ban-client set sshd unbanip 10.20.30.66
1

Fail2ban is not only for SSH; there are jails that read Nginx logs too (nginx-http-auth, nginx-botsearch). For limiting requests to the API, though, the application's or Nginx's rate limiting is the better place; the HTTPS and rate limiting post of the Web Servers and Hosting series covers it.

Least Privilege and Auditing

The inside view of hardening is limiting the damage when something is compromised. I checked the setup from the earlier posts one by one:

$ sudo -l -U deploy | tail -n 2
User deploy may run the following commands on bookstore-dev:
    (root) NOPASSWD: /usr/bin/systemctl restart bookstore-api, /usr/bin/systemctl status bookstore-api
$ sudo stat -c "%A %U:%G %n" /etc/bookstore /etc/bookstore/secrets.env /opt/bookstore/current/appsettings.json
drwxr-x--- root:bookstore /etc/bookstore
-rw-r----- root:bookstore /etc/bookstore/secrets.env
-rw-r--r-- deploy:bookstore /opt/bookstore/current/appsettings.json
$ getent passwd bookstore
bookstore:x:999:988::/home/bookstore:/usr/sbin/nologin
$ ps -o user=,pid=,cmd= -C dotnet
booksto+     657 /usr/bin/dotnet /opt/bookstore/current/Bookstore.Api.dll
$ systemd-analyze security bookstore-api --no-pager | tail -n 1
→ Overall exposure level for bookstore-api.service: 9.2 UNSAFE :-{

deploy can run only two commands as root (third post). Only root can write the secrets file and only the bookstore group can read it. The application runs as a system user without a login shell and cannot change the release files because they belong to deploy. The only missing layer is the systemd sandbox: the score is 9.2. I added the hardening settings from the systemd post of the Web Servers and Hosting series to the unit itself (in the repo as deploy/linux/systemd/bookstore-api.service; that post explains each setting):

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
LockPersonality=true
CapabilityBoundingSet=
ReadWritePaths=/var/lib/bookstore
$ sudo systemctl daemon-reload && sudo systemctl restart bookstore-api
$ systemctl is-active bookstore-api
active
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5000/api/books
200
$ systemd-analyze security bookstore-api --no-pager | tail -n 1
→ Overall exposure level for bookstore-api.service: 3.8 OK :-)

Auditing comes down to two questions: who logged in, and what did they do. last lists logins that opened a terminal, from /var/log/wtmp; connections that run a command and leave (ssh host command) do not show up there. The complete list is in sshd's journal entries:

$ last -n 2 -i
ada      pts/0        10.20.30.5       Sat Nov 14 17:22 - 17:22  (00:00)
ada      pts/0        10.20.30.5       Sat Nov 14 17:21 - 17:21  (00:00)

wtmp begins Sat Nov 14 14:04:11 2026
$ journalctl _COMM=sshd --since today --no-pager -o cat | grep -oE "^(Accepted [a-z]+ for [a-z]+|Invalid user [a-z]+) from [0-9.]+" | sort | uniq -c | sort -rn
    285 Accepted publickey for ada from 10.20.30.5
      4 Accepted password for ada from 10.20.30.5
      1 Invalid user test from 10.20.30.66
      1 Invalid user oracle from 10.20.30.66
      1 Invalid user oracle from 10.20.30.5
      1 Invalid user admin from 10.20.30.66
      1 Invalid user admin from 10.20.30.5
$ journalctl _COMM=sudo --since "-30min" --no-pager -o short-iso | grep COMMAND | tail -n 2 | cut -c1-170
2026-11-14T17:32:58+00:00 bookstore-dev sudo[5862]:      ada : PWD=/home/ada ; USER=root ; COMMAND=/usr/bin/systemctl daemon-reload
2026-11-14T17:33:05+00:00 bookstore-dev sudo[5908]:      ada : PWD=/home/ada ; USER=root ; COMMAND=/usr/bin/systemctl restart bookstore-api

The grep -o | sort | uniq -c chain from the seventh post has turned into a security report. The four "Accepted password" lines are logins from before password authentication was turned off, including the one in the guess; after that this number must not grow, and it is a number to check regularly. The sudo entries record who ran which command as root; this is where everyone logging in with their own account pays off.

We covered automatic security updates in the ninth post; they are on for this server (APT::Periodic::Unattended-Upgrade "1"), and you schedule the reboot after a kernel update. The last item on the hardening list is the inventory: systemctl list-units --type=service --state=running showed 22 services on this VM. Turning off services that have no job on a server, such as ModemManager or udisks2 that come with the cloud image, shrinks the attack surface, but do not turn anything off without knowing what it does.

Production Notes

Do everything in this post with code, not by hand. 10-bookstore.conf, bookstore.local and the unit file are in the repo; the ufw commands belong in the script or configuration management tool that builds the server. That is the only reliable way not to skip a hardening step when you open a new server. The upcoming Infrastructure as Code: Terraform series covers building the server, and the Bash Scripting series covers setup scripts like these.

In the cloud there are two firewall layers: ufw inside the server and the rules in the provider's network layer (a Network Security Group on Azure, a security group on AWS). Use both, and open SSH only to an office or VPN address if you can. Check that you have access to the provider's web console and that it works before you lock yourself out; that console is the fallback for the second session rule.

Verify the hardening. Writing the setting is not enough: sshd -T shows the effective value, ufw show listening the open ports, and nc -zv or curl from another machine what is visible from the outside. Do this after every change and at regular intervals; as in the Docker example, the next installation can quietly make a rule ineffective.

Common Pitfalls

Changing the main sshd_config and forgetting sshd_config.d/. As we saw in the guess, the drop-in files are read first and the first value wins. Always verify the setting with sshd -T.

Changing SSH settings with a single session. Leaving yourself out of AllowUsers, a wrong AllowGroups or turning off passwords before adding your key are all valid configs; sshd -t does not catch them. Do not close the first session until a new connection from a second terminal succeeds.

Enabling ufw without allowing SSH. The existing session survives and you think everything is fine; once the connection drops, you cannot get back in. ufw allow OpenSSH before ufw enable.

Assuming ufw also protects Docker. -p 8080:80 opens the port on every address and ufw's rules do not apply to it. Publish container ports on 127.0.0.1 only and test from the outside.

Treating Fail2ban as a replacement for turning off passwords. Fail2ban slows attempts down, but it cannot catch a slow attack from many addresses. The real protection is key-based login; Fail2ban is an extra layer that keeps the log clean.

A Small Experiment

★☆☆ On your own server run sudo sshd -T | grep -E "^(passwordauthentication|permitrootlogin|kbdinteractiveauthentication) " and list the files under sshd_config.d/. Which file does the effective value come from?

★★☆ On a test VM set up ufw in the right order from this post, then check that ufw show listening leaves no port without a rule. From another machine try 22, 80, 443 and 5000 with nc -zv and explain the results.

★★★ Write a Fail2ban jail that reads /var/log/nginx/bookstore.access.log and bans addresses producing many 404s in a short time. Test your filter against the log with fail2ban-regex, and do not forget ignoreip.

We have hardened the server against the outside. In the next post we look at what to do when something goes wrong: a troubleshooting playbook from the "site is down" complaint to the root cause, with three realistic Bookstore cases.