The Starting Point
A Fedora 43 VPS on Hetzner. Caddy serving a static site over HTTPS. SSH open on port 22 with public key auth. No fail2ban, SELinux in permissive mode, no automatic updates.
Good enough to serve a static site. Not good enough to leave alone.
How Much Noise Is There on Port 22?
The server had been up for 16 days. Before touching anything, I pulled the last 24 hours to get a sense of the baseline noise:
journalctl -u sshd --no-pager --since '24 hours ago' \
| grep -oE 'from [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \
| awk '{print $2}' \
| sort | uniq -c | sort -rn | head -20
6672 196.188.63.205
1116 87.106.91.226
566 2.57.122.177
310 92.118.39.63
300 195.178.110.30
292 92.118.39.62
269 170.64.210.249
250 193.24.211.93
200 80.94.92.183
140 2.57.121.25
The top IP alone hit the server 6,672 times in 24 hours — about 4-5 attempts per minute, nonstop. The 2.57.x.x and 92.118.39.x ranges are clearly coordinated botnets. Common usernames attempted: root, api, sol, solana, filecoin — credential stuffing with a crypto theme.
None of them got in because pubkey auth was already enforced. But letting them hammer away indefinitely wastes resources and fills logs with noise. Time to cut it off.
Step 1: Install fail2ban
dnf install -y fail2ban
Create /etc/fail2ban/jail.local — this overrides the defaults without touching the upstream config:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
port = 7822
logpath = %(sshd_log)s
backend = systemd
5 failures within 10 minutes earns a 1-hour ban. Enable and start it:
systemctl enable --now fail2ban
Check it:
fail2ban-client status sshd
Within minutes of starting, 4 IPs were already banned.
Step 2: Move SSH Off Port 22
Port 22 is the first port every scanner checks. Moving to a high unprivileged port (not 2222 — that’s also scanned) drops nearly all automated noise instantly. Port 7822 is easy to remember and off the common scan lists.
Edit /etc/ssh/sshd_config:
sed -i 's/^#Port 22$/Port 7822/' /etc/ssh/sshd_config
Update firewalld to close 22 and open 7822:
firewall-cmd --permanent --add-port=7822/tcp
firewall-cmd --permanent --remove-service=ssh
firewall-cmd --reload
Before restarting sshd, tell SELinux the new port is legitimate — more on why in the next section:
semanage port -a -t ssh_port_t -p tcp 7822
Now restart:
systemctl restart sshd
Before closing your current session, verify the new port is reachable:
ssh -p 7822 root@your-server-ip "echo OK"
Document it locally in ~/.ssh/config:
Host yourserver.example.com
HostName your-server-ip
User yourusername
Port 7822
After that, ssh yourserver.example.com just works.
Step 3: SELinux — Permissive to Enforcing
Fedora ships with SELinux, but Hetzner images default it to permissive — it logs violations but doesn’t block anything. Flip it to enforcing:
# Take effect immediately
setenforce 1
# Persist across reboots
sed -i 's/^SELINUX=permissive/SELINUX=enforcing/' /etc/selinux/config
Verify:
getenforce
# Enforcing
Why the Port Label Matters
SELinux maintains a strict list of ports each service is allowed to bind to. Port 22 is on sshd’s list. Port 7822 is not — which is exactly what you want from a MAC system. The semanage command from Step 2 adds 7822 to that list:
semanage port -a -t ssh_port_t -p tcp 7822
Do this before SELinux goes enforcing and before restarting sshd, and everything works on the first try.
Step 4: Automatic Security Updates
Fedora 43 uses dnf5, so the package is dnf5-plugin-automatic:
dnf install -y dnf5-plugin-automatic
Create /etc/dnf/automatic.conf:
[commands]
apply_updates = yes
download_updates = yes
upgrade_type = security
reboot = never
[emitters]
emit_via = stdio
emit_no_updates = no
Enable the timer:
systemctl enable --now dnf5-automatic.timer
upgrade_type = security limits it to CVE patches only — it won’t pull in feature updates that could break things.
Step 5: Non-Root User
Logging in as root directly is unnecessary risk. Create a dedicated user, copy the authorized keys, and give it passwordless sudo — access is already gated by SSH key, so the password prompt adds friction without adding security.
useradd -m -G wheel yourusername
mkdir -p /home/yourusername/.ssh
cp /root/.ssh/authorized_keys /home/yourusername/.ssh/
chown -R yourusername:yourusername /home/yourusername/.ssh
chmod 700 /home/yourusername/.ssh
chmod 600 /home/yourusername/.ssh/authorized_keys
echo 'yourusername ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/yourusername
chmod 440 /etc/sudoers.d/yourusername
Verify the new user works before disabling root:
ssh -p 7822 yourusername@your-server "id && sudo id"
You should see uid=1000(yourusername) then uid=0(root). Once confirmed:
sed -i 's/^PermitRootLogin prohibit-password/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart sshd
Where Things Stand
A quick DNS lookup shows brettlyons.dev resolves to Cloudflare IPs, not the origin — web traffic never reaches the server directly. That also means a Caddy fail2ban jail isn’t useful here; all requests appear to come from Cloudflare IPs rather than real clients.
SSH is locked down, SELinux is enforcing, security patches apply automatically, and the web layer has Cloudflare in front of it. Solid baseline.
This post was written with the assistance of Claude.