Hardening a fresh VPS: the checklist we actually use
The first-hour checklist for a new server: SSH, firewall, kernel, updates, logging, and the handful of steps that matter far more than the rest.
How do I secure a new VPS properly?
Server hardening guides tend to be long lists in which every item looks equally important. They are not. Four things prevent almost every real compromise, and everything after them is diminishing returns. Here is the list in the order the return actually diminishes.
The four steps that matter most
- Key-only SSH. Automated password guessing against port 22 is continuous, universal, and the single most common way servers are taken. Turning off password authentication ends it categorically.
- Default-deny firewall. Most compromises come through a service the operator did not know was listening. If the default is DROP, an accidentally exposed service is not exposed.
- Automatic security updates. The gap between a CVE being published and mass exploitation is now measured in hours. Nobody patches manually fast enough.
- Turn off what you are not using. The smallest attack surface is the one that is not running. Check what is listening and disable everything you cannot justify.
Do these four before you deploy your application. They take about ten minutes.
SSH configuration
# /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 20
AllowUsers deploy
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
ClientAliveInterval 300
ClientAliveCountMax 2
Use Ed25519 keys. Better still, use a hardware token — ssh-keygen -t ed25519-sk — which makes key theft require physical possession of the token.
Test in a second terminal before closing the first. Locking yourself out of a remote server is an experience you only need once, and on an encrypted machine it means booting rescue media.
Moving off port 22 does not improve security in any meaningful sense — it reduces log volume by roughly 99%, which is worth doing precisely because it makes the remaining entries readable.
Leave the algorithm lists alone
Something is missing from that file, and its absence is the part of this article we get argued with about: there is no Ciphers line, no MACs line, no KexAlgorithms line and no HostKeyAlgorithms line. We took them out and we are not putting them back.
The case for pinning was always that the shipped defaults contained something you would not want negotiated. On a current OpenSSH they do not. CBC-mode ciphers have not been in the default cipher list for years, RSA signatures over SHA-1 have been off by default since 8.8, and the one item people still point at — [email protected] in the default MAC list — is not a practical weakness, because collisions in SHA-1 do not give you forgeries in HMAC-SHA-1.
The case against pinning is that the default list is maintained and yours is not. Key agreement is the clearest example. OpenSSH 9.0, in April 2022, made the hybrid post-quantum [email protected] the default; 9.9 added mlkem768x25519-sha256 and 10.0, in April 2025, made that the default in turn. A machine still carrying the KexAlgorithms curve25519-sha256,[email protected] line that every checklist recommended in 2020 has been negotiating classical-only key agreement through both of those changes, and will keep doing so until somebody edits the file. Upstream's own post-quantum page tells operators whose connections are not going post-quantum to go and look at whether a KexAlgorithms setting turned it off. That is the failure mode: a list written once, correct on the day, silently ageing into a downgrade.
If you genuinely must constrain the negotiation — a compliance regime that names an algorithm, an auditor with a scanner — subtract from the default instead of replacing it, so the rest of the list keeps tracking upstream:
MACs -hmac-sha1*,umac-64*
KexAlgorithms -ecdh-sha2-nistp*,diffie-hellman-group14-sha256
The leading - is documented behaviour: the names that follow, wildcards included, are removed from whatever the current default set is, and everything else in it stays. Your file then says what you object to rather than what you happened to approve of in the year you wrote it.
The effort you were going to spend curating cipher suites is better spent on who can authenticate and from where. Key-only with a hardware token, AllowUsers naming one account, a short grace time, and — if the machine will tolerate it — sshd bound to a WireGuard interface or reachable only as an onion service, so the negotiation you were hardening is not offered to the internet at all.
Firewall: default deny
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } accept
ip6 nexthdr icmpv6 accept
tcp dport 2222 ct state new limit rate 6/minute accept # ssh
tcp dport { 80, 443 } accept
counter comment "dropped"
}
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }
}
The rate limit on the SSH port turns brute-force attempts into a non-event without needing fail2ban. If you want egress filtering as well, set the output policy to drop and allow explicitly — worth doing on machines handling sensitive data, since it constrains what an attacker can exfiltrate to.
Kernel and sysctl
# /etc/sysctl.d/99-hardening.conf
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.unprivileged_bpf_disabled = 1
kernel.yama.ptrace_scope = 1
net.core.bpf_jit_harden = 2
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.tcp_syncookies = 1
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_ra = 0
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
fs.suid_dumpable = 0
Add lockdown=confidentiality to the kernel command line if you are not loading out-of-tree modules. It blocks a set of interfaces — including /dev/mem and unsigned module loading — that a root-level attacker would otherwise use to reach kernel memory.
Enable unattended upgrades:
apt install unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
# and check it is actually running:
unattended-upgrade --dry-run --debug
Logging you cannot quietly erase
Logs on the compromised machine are logs the attacker can edit. If it matters, ship them off the box as they are written — to a log host, an append-only object store, or simply another instance in a different region. The cheap version is a rsyslog forward over TLS; the good version adds a hash chain so tampering is detectable.
Also consider what you are logging about your users. Most default configurations log client IP addresses in web server access logs indefinitely, which is a liability rather than an asset for most privacy-minded operators. Truncate the last octet, or turn access logging off entirely:
# nginx: log without the client address
log_format privacy '- - [$time_local] "$request" $status $body_bytes_sent';
access_log /var/log/nginx/access.log privacy;
# or simply
access_log off;
Things you can safely skip
- fail2ban, if SSH is key-only and rate-limited. It is parsing logs to solve a problem you have already eliminated, and its log-parsing regexes have themselves had vulnerabilities.
- Port knocking. Meaningful obscurity, real operational pain, and it breaks your automation. Key-only SSH already closed the door.
- Antivirus on a Linux server. Almost always scanning for Windows malware. ClamAV has a place on a mail gateway and essentially nowhere else.
- Disabling ICMP entirely. Breaks path MTU discovery and produces mysterious connection hangs. Rate-limit it instead.
- Elaborate SELinux or AppArmor policies on day one. Worth doing eventually, and a common way to spend an afternoon producing a policy in permissive mode that protects nothing. Get the first four right first.
VPSDEN's managed hardening baseline applies and maintains this configuration, with a signed weekly drift report, for €5/month. The full policy is published so you can apply it yourself without buying anything.