MRPH·LAB

TX·05 · LINUX / HARDENING · LOGGED 2026·08 · 14 MIN

Hardening a Linux VPS: what stops the bots and what's just theater

I rented a small VPS to stage Digital Descent's backend, and the auth log had password guesses in it before I'd finished my coffee. Nobody was targeting me. Nobody ever needed to: bots hit every routable IPv4 address on every common port, forever. That threat model, the automated one, turns out to reorder the whole hardening playbook. Here's what actually matters on a single exposed Linux box, what branches per distro, and which folklore you can safely delete, tested on my own machines.

The attacker is a for-loop

Everything in this article is me hardening and testing my own servers, and all of it assumes one specific adversary: the automated kind. Credential stuffers, botnet scanners, mass-exploit scripts that spray a fresh CVE at the whole IPv4 space within days of the patch shipping. The scale is not folklore. A measurement paper from July 2025 (Rieck et al., arXiv 2507.09022) puts the average at roughly 250,000 unauthorized access attempts per internet-facing server per month, and finds only about a third of exposed servers require anything stronger than a password. My little staging box gets its share of that quarter-million like everyone else. It is volume, not interest.

Optimizing for that adversary reorders your priorities. A for-loop doesn't care that you moved SSH to port 2222 or that your cipher list came from a very serious blog post. It cares whether password auth is on, whether you're running a version with a known exploit, and whether you left something listening that you forgot about. So the five controls that carry nearly all the weight are: key-only SSH with root login off, automatic security updates, a default-deny firewall, the shortest possible list of listening services, and your distro's mandatory access control left enforcing. Everything else in this article is refinement on those five. None of it is sufficient against a resourced, targeted adversary, but that's a different program with a different budget, and it isn't who is knocking on a hobby VPS at 3 a.m.

SSH first: keys, then leave the crypto alone

SSH is the most-attacked service on any public box, and the fix is almost boring: eliminate passwords, don't expose root, patch fast. The config file is the same path and syntax on every distro I run, which makes this the most portable section of the whole article. Modern releases read drop-ins from /etc/ssh/sshd_config.d/, so leave the vendor file alone:

/etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers deploy

Order of operations matters here, because this is a change that can lock you out. Generate an Ed25519 key with ssh-keygen -t ed25519, install the public half, confirm a key login works, and only then turn passwords off. Validate before restarting, and keep a second session logged in while you do it:

validate, then restart
sshd -t
sshd -T | grep -Ei 'permitrootlogin|passwordauth|pubkey'
systemctl restart sshd    # rc-service sshd restart on Alpine

With passwords off, credential stuffing is over as a category. The bots keep guessing, the guesses stop mattering, and the remaining SSH risks are a leaked private key, a compromised client, or a bug in sshd itself. AllowUsers is the allowlist version of account control, which beats blocklisting the same way default-deny beats default-allow everywhere else. If you need exceptions, a Match block scopes them: I allow password auth from my LAN's address range on one bench machine and nowhere else, and that's the entire list of exceptions I've ever needed.

Now the folklore. Every hardening listicle since 2015 ships a wall of Ciphers, MACs, and KexAlgorithms to paste in, and on a current distro that wall is somewhere between useless and harmful. OpenSSH's compiled-in defaults are already modern: curve25519, a post-quantum hybrid key exchange, ChaCha20-Poly1305 and AES-GCM, encrypt-then-MAC. A pasted list from years ago pins you to the past and quietly excludes better algorithms as they arrive. If a compliance scanner genuinely forces your hand, subtract the offending algorithm with the minus syntax, Ciphers -aes128-cbc, instead of replacing the whole list. Otherwise leave the crypto alone. That's not laziness, it's the engineering call: the defaults are maintained by the people who wrote the protocol, and my pasted list is maintained by nobody.

Changing the SSH port deserves an honest classification: it's log hygiene, not security. Moving off 22 cuts scan noise dramatically, one documented single-server test that moved to port 222 saw a 98 percent drop in attempts over a week, and quieter logs are genuinely worth something when you're the one reading them. But scanners fingerprint services by their banner, not their port, so it stops nothing that decides to look. If you do it for the log noise, fine, I did on one box. Pick a port below 1024 so an unprivileged process can't claim it after a crash, and know exactly what you bought.

The case for treating sshd patching as sacred has a name: regreSSHion, CVE-2024-6387, a pre-auth root RCE from a signal-handler race, affecting versions 8.5p1 through 9.7p1 on glibc systems and fixed in 9.8p1 back in July 2024. No config setting saved you. The workaround of LoginGraceTime 0 traded the RCE for a denial-of-service vector. The fix was the patch, which is exactly why the next section outranks this one.

Patch faster than the botnet

Unpatched known-CVE software is how mass exploitation actually happens, and the regreSSHion timeline is the case study: patches were available within hours, and boxes were still getting picked off weeks later. The same story played out with every WebSocket library CVE I walked through in TX·04. Speed is the control. A human remembering to run upgrades is not speed, so the real question per distro is what does automatic patching look like.

On Debian and Ubuntu it's unattended-upgrades, installed and pointed at the security origin by default once you enable it (dpkg-reconfigure -plow unattended-upgrades). Scope it to security updates and give it a reboot window, because a patched kernel on disk protects nothing while the vulnerable one keeps running:

/etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:30";

The RHEL family equivalent is dnf-automatic: install it, set security-only mode, enable the install timer:

/etc/dnf/automatic.conf
[commands]
upgrade_type = security
apply_updates = yes
reboot = when-needed

openSUSE schedules zypper patch from a timer. Arch and Alpine have no unattended mechanism at all, and that's a deliberate design choice, not an oversight: on a rolling distro an unattended upgrade can pull a breaking change at 4 a.m. with nobody watching. The ArchWiki states it as an imperative: you must "keep up to date with changes in Arch Linux that require manual intervention" before upgrading. Tools like arch-audit (checks installed packages against the security tracker) and the informant pacman hook (blocks a transaction while news items sit unread) make the manual routine safer, but it stays a routine a human has to run. My call: if you won't commit to reading the news and upgrading on a schedule, don't put Arch or Alpine on a public address. Debian with unattended security updates is not exciting, and not exciting is the feature.

One trap left: kernel, glibc, and OpenSSL updates don't take effect until a reboot or service restart, so "auto-updates enabled" plus "999 days uptime" means you're accumulating patched-but-inactive kernels while the old one keeps serving exploits. Check /var/run/reboot-required on Debian-family, set the reboot window, and if reboots are genuinely scarce, kernel livepatching (Canonical Livepatch, kpatch, SUSE's live patching) covers the kernel piece. Only the kernel piece: glibc still wants its reboot.

Default-deny, whichever front-end you get

Every open port is surface, and a default-deny inbound policy means the service you forgot, or the one an intruder starts, isn't reachable just because it's listening. The part that surprised me when I started keeping cross-distro notes: underneath, everybody is running the same firewall. The kernel enforcement layer is netfilter programmed via nftables on every modern distro. The distros only disagree about which tool writes the rules: ufw on Debian and Ubuntu, firewalld on the RHEL family and openSUSE, awall on Alpine, and raw nft on Arch or anywhere you want to see the actual ruleset. Fighting your distro's blessed front-end buys you nothing; pick the one it ships and move on.

The ufw version is four lines and includes rate limiting, since limit tempbans an address that opens six or more connections in thirty seconds:

ufw, Debian/Ubuntu
ufw default deny incoming
ufw default allow outgoing
ufw limit 22/tcp
ufw allow 80,443/tcp
ufw enable

On firewalld it's the same policy spelled as services and a rich rule for the SSH limit. On my Arch bench box I write the nftables ruleset directly, which is worth doing at least once anyway because it shows you what every front-end is generating. Default-deny input, loopback and established traffic accepted, a per-IP meter on new SSH connections:

/etc/nftables.conf
flush ruleset
table inet firewall {
  set ssh_meter { type ipv4_addr; size 65535; flags dynamic,timeout; timeout 1m; }
  chain input {
    type filter hook input priority 0; policy drop;
    ct state vmap { established : accept, related : accept, invalid : drop }
    iifname lo accept
    ct state new tcp dport 22 add @ssh_meter { ip saddr limit rate 10/minute } accept
    tcp dport { 80, 443 } accept
    ip protocol icmp icmp type echo-request limit rate 5/second accept
  }
  chain forward { type filter hook forward priority 0; policy drop; }
}

Two principles hiding in there. Rate-limit only ct state new, so established sessions never feel the limiter, and meter per source IP rather than globally, so one noisy scanner can't consume the allowance for everyone. And one warning that saves real pain: don't mix managers. Old iptables scripts still circulate and mostly still work through the compatibility shim, but a hand-rolled script fighting ufw or firewalld over the same chains produces rules that half-apply and failures nobody can reproduce. One writer per firewall.

fail2ban, sshguard, CrowdSec: one real edge

Here's the reframe that took me too long: once SSH is key-only, banning brute-forcers isn't defense, it's janitorial. The guesses were already guaranteed to fail; the ban just keeps the log readable. So the honest pitch for this tool family is log hygiene for SSH, plus real protection for whatever else parses credentials, web logins and mail, and one genuinely distinct capability I'll get to.

fail2ban is the incumbent: log parsing, temporary firewall bans, filters for nearly everything, packaged everywhere (EPEL on the RHEL family). The modern backend rides the systemd journal and bans via nftables:

/etc/fail2ban/jail.local
[DEFAULT]
banaction = nftables-multiport
backend = systemd

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

On Alpine there's no journal, so backend = auto plus an explicit logpath pointed at syslog's output. sshguard does the same job in a smaller C daemon with a narrower filter library. CrowdSec is the different one: its agent detects locally, a separate bouncer enforces, and the community blocklist means an IP that spent the morning hammering other people's servers can be blocked on yours before its first packet arrives. That's crowd-sourced IP reputation, the same species of signal I was building by hand in TX·02, delivered as a feed. It's also a multi-component stack where fail2ban is one Python process, so the complexity has to earn its keep; on a single box I file it under nice, not necessary.

Know what none of them can do. They're reactive log parsers: a distributed botnet making one attempt per IP never trips a per-IP threshold, a single-shot exploit is done before any threshold counts to two, and a login with valid stolen credentials looks like you. Layer, not foundation.

Fewer listeners, then sandbox the survivors

The service you don't run can't be exploited. Before hardening anything, I run ss -tulpen and read the output like an outsider would: every socket bound to 0.0.0.0 or :: is a public offer. Databases, metrics endpoints, and admin panels that only local processes need should be rebound to 127.0.0.1; services nobody needs should be disabled outright. This ten-minute audit is the highest ratio of risk-removed to effort-spent in the whole piece, the same argument I made about Express middleware surface in TX·01, one layer down the stack.

What survives the audit gets sandboxed. On systemd distros, which is everything here except Alpine, per-service confinement is nearly free: never edit the vendor unit, drop an override in via systemctl edit, and let systemd-analyze security <svc> score you. Fresh packaged services routinely score around 9.6 of a possible terrible 10; a working override lands in the 2-to-5 range:

systemctl edit nginx
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
SystemCallFilter=@system-service
SystemCallArchitectures=native
RestrictSUIDSGID=yes
LockPersonality=yes
ReadWritePaths=/var/log/nginx /var/cache/nginx

Apply in phases, restart, re-score, watch the journal. ProtectSystem=strict makes the whole filesystem read-only except your declared ReadWritePaths, so the first run usually surfaces a path you forgot as a loud read-only-filesystem error naming the exact directory. That's the directive doing its job. The four that carry most of the score: NoNewPrivileges, ProtectSystem=strict, PrivateTmp, and a tight CapabilityBoundingSet.

Alpine deserves a straight answer here: it runs OpenRC, not systemd, so none of this machinery exists there. No Protect* directives, no syscall filters, no scorer. supervise-daemon gives you ulimits and cgroup caps, and beyond that you're reaching for AppArmor, Bubblewrap, or a container to get comparable isolation. I like Alpine a lot in containers; for a bare public VPS where per-service sandboxing is part of the plan, the systemd distros hand it to you and Alpine makes you build it.

sudo, doas, and accounts nobody uses

With root login off, daily driving happens as an unprivileged user, which makes the escalation tool part of the attack surface. sudo's track record here is humbling: CVE-2021-3156, Baron Samedit, was a heap overflow to full root that sat in the code for nearly a decade before anyone noticed. That's not an argument against sudo so much as a reminder that 177,000 lines of C is a lot of places for a bug to hide. doas, OpenBSD's alternative, is around 3,000 lines, and its entire config on my boxes is one line, permit persist :wheel. Alpine ships it as the default. The port isn't magically bug-free either (the Linux port has carried its own TTY-related CVE), but two orders of magnitude less code is a real difference in how much you're trusting. My split: doas on single-admin boxes, sudo where I actually need its policy granularity, scoped tightly in /etc/sudoers.d/ via visudo and never with blanket NOPASSWD: ALL on anything public.

The rest is account hygiene that takes five minutes and closes real doors: audit /etc/passwd, give system accounts nologin shells, lock anything unused with usermod -L, confirm no account except root carries UID 0, and grant admin through the wheel or sudo group instead of per-user scatter.

The sysctl short list

Kernel network knobs are where hardening guides go to bloat. The syntax and path are identical everywhere, a file in /etc/sysctl.d/ applied with sysctl --system, and the list that earns its place on an internet-facing host is short:

/etc/sysctl.d/99-hardening.conf
# spoofing and redirect games
net.ipv4.conf.all.rp_filter = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
# not a router
net.ipv4.ip_forward = 0
# info-leak hardening
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 1

Before crediting your new file with anything, check what was already set: tcp_syncookies and rp_filter are on by default in most modern distros, and Debian ships several of these values out of the box. And know the three that bite back. Strict rp_filter breaks asymmetric routing setups, use loose mode (2) if you're multi-homed. ip_forward = 0 breaks Docker and Podman networking, and the container runtime writes its own sysctl file, so on a container host leave forwarding to it rather than winning a file-ordering fight you didn't know you entered. ptrace_scope = 1 breaks cross-process debugging for non-root, which is correct on a server and infuriating on a dev box. Meanwhile the classics people still paste, like disabling TCP timestamps and ECN, buy nothing against bots and can cost real performance. Skip them.

Leave the MAC on

Mandatory access control is the backstop for the day a service gets popped anyway: the compromised daemon stays confined to what its policy allows even if it's running as root. Which one you have was decided by your distro. The RHEL family and Fedora ship SELinux enforcing; Debian and Ubuntu ship AppArmor; openSUSE, after years as AppArmor's flagship, flipped to SELinux (Tumbleweed in early 2025, Leap 16 at release). Arch and Alpine ship neither by default, which fits their you-assemble-it philosophy and is one more quiet argument for boring distros on public boxes.

The only advice most people need is negative: when something breaks, don't turn it off. setenforce 0 is the most-typed wrong answer in Linux administration, and disabling the LSM to fix one service strips protection from all of them. The right loop on SELinux is to read the actual denial with ausearch -m avc -ts recent, then fix the file context or flip the relevant boolean, reaching for audit2allow only when you understand what you're granting. On AppArmor, put the noisy profile in complain mode, reproduce, and let aa-logprof update it. Every denial has a specific cause, and the tools name it if you ask.

Logs the intruder can't reach

Nothing above prevents everything, so you want to see what happened, and you want the record to survive the person it describes. Step one is just making logs persistent: journald on most distros defaults to losing everything at reboot unless Storage=persistent is set in journald.conf, with SystemMaxUse=2G so it can't eat the disk. Alpine, again the outlier, has no journald: BusyBox syslogd writes /var/log/messages and every journalctl incantation in every guide silently doesn't apply.

Step two, for the things syslog never sees, is auditd: file watches and syscall rules that record someone reading /etc/shadow or exec-ing their way to euid 0. The RHEL family ships it by default for compliance reasons; it's a package away elsewhere. The trap is rule greed. Auditing every execve on a busy server produces volume you will never read and overhead you will definitely pay. Watch the files whose modification means something is already wrong:

/etc/audit/rules.d/50-identity.rules
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/ssh/sshd_config -p wa -k sshd_config
-w /etc/sudoers -p wa -k priv
-a always,exit -F arch=b64 -S execve -C uid!=euid -F euid=0 -k setuid_exec

Step three is the one that actually changes the game: get a copy off the box. An intruder with root can stop auditd and edit anything local, and none of it helps them if rsyslog or syslog-ng has been forwarding over TLS to a collector that only accepts writes. Now hiding requires compromising a second machine that exposes exactly one inbound port. For one VPS the collector can be nearly anything, a container on my bench rig does it here; what matters is that the copy exists somewhere the first box can't reach back into.

The checklist

The whole article, compressed into the list I ran against my own staging box, roughly in order of risk removed:

The thread through all of it: against an adversary that is a script, the wins come from eliminating whole categories, passwords that can't be guessed because there are none, exploits that don't land because the patch beat the scanner, ports that don't answer because nothing listens. Everything here ran on my own bench and my own staging VPS, Debian 13 on the public box, AlmaLinux and Arch and Alpine VMs for the branches, and the auth log is still full of guesses. They just don't matter anymore.