
Harden a new VPS in the first hour
A public IPv4 address is being probed within minutes of its first packet, by machines that have never heard of you and never will. That is good news: almost everything aimed at a new server is generic, and one deliberate hour defeats nearly all of it. The same hour, done in the wrong order, locks you out of a machine nobody can log into on your behalf.
We hand you a root shell about 47 seconds after payment clears and then, deliberately, stop. We do not install an agent, we do not manage your firewall, and we do not hold a copy of your credentials — a key we hold is a key we can be compelled to produce, and that principle runs through the whole platform. The consequence is simple and worth saying plainly: the security of your server is the state you leave it in during the first hour.
What follows is that hour, in the order we run it ourselves, on Debian 13 and Ubuntu 24.04. Two settings do most of the work. The rest of this page exists because of three things that look correct, pass every tutorial's final check, and are quietly wrong: a drop-in SSH config that is silently overridden, a port setting that a socket-activated sshd ignores, and a container runtime that publishes ports underneath your firewall. Each has bitten someone who did everything else right.
What is actually knocking on the door
Watch journalctl -u ssh on a server that has been online for an hour and the volume is alarming the first time you see it. It should not be. What you are looking at is the background radiation of the internet: a handful of scanning operations, some academic, some commercial, some criminal, that enumerate the entire IPv4 space continuously and hand the results to credential-guessing bots. Your address was reached because it exists, in numerical order, and it will be reached again in a few hours whatever you do.
This changes the shape of the problem in a useful way. You are not defending against an adversary who has chosen you and will adapt; you are defending against a script with a fixed repertoire — root, admin, ubuntu, test, git, oracle, postgres, and the twenty thousand passwords that have appeared in breach dumps. It has no patience, no creativity and no interest in a machine that does not answer on the first try. Turning off password authentication does not slow this attacker down. It removes it from the board entirely.
Two details are worth knowing. First, IPv6 is dramatically quieter, because a /64 cannot be swept — but the moment your AAAA record is public, or your machine's address appears in a mail header or a certificate-transparency log, the quiet ends. Never treat IPv6 as a hiding place; treat it as a smaller haystack. Second, an IPv4 address has a past. It had a tenant before you, and if that tenant ran a mail server badly or hosted something that got listed, you inherit the reputation until it decays. If mail matters to you, check the address on the usual blocklists before you build on it — that is a five-minute check that saves a fortnight of deliverability debugging.
The two settings that do ninety percent of the work
Almost every real compromise of a small server begins in one of two places: a password that could be guessed, or a service that was listening and should not have been. The corresponding fixes are PasswordAuthentication no and a firewall with a default drop policy. They are unglamorous, they take fifteen minutes together, and they are worth more than every other control on this page combined.
The reason is structural rather than statistical. Both are closed by default — they fail toward safety. A keys-only sshd cannot be brute-forced no matter how many attempts arrive, because there is no path through the code that accepts a password. A default-deny firewall protects services you have not installed yet, including the database you will add in three months and forget to bind to localhost. Everything else in a hardening checklist is enumeration of badness: a list of specific things to switch off, which is only ever as complete as the list.
So if you read one section and close the tab, read this one, do steps 2 through 5 below, and consider the hour well spent. The rest is genuinely useful and genuinely secondary.
Where SSH config lives now, and the trap in it
On Debian 13 and Ubuntu 24.04, /etc/ssh/sshd_config opens with an Include /etc/ssh/sshd_config.d/*.conf line, and cloud images ship a file in that directory — usually 50-cloud-init.conf — that already sets PasswordAuthentication. Editing the main file and appending your own directives at the bottom feels natural and produces a config that does not do what it says.
The rule is the one thing about sshd that surprises nearly everyone: for each keyword, the first value obtained wins. This is the opposite of almost every other config-merging system you have used. The Include sits near the top of the main file, so a drop-in is read before the body of sshd_config — and among drop-ins, alphabetical order decides. A file named 10-hardening.conf beats 50-cloud-init.conf. A file named 60-hardening.conf loses to it, silently, and you will not learn this from a restart that reports success.
Which is why you never trust the file you just wrote. Ask the daemon what it actually resolved:
sshd -t # syntax check — silence means valid
sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication|kbdinteractive|allowusers|port) 'sshd -T prints the effective configuration after every include, override and default has been resolved. If that output says passwordauthentication yes, then password authentication is on, regardless of what any file on disk claims. Nothing else counts as verification.
The second trap is in the same family. Ubuntu 24.04 starts sshd through socket activation: ssh.socket owns the listening port, and Port in sshd_config is ignored entirely. Check with systemctl is-enabled ssh.socket; if it is enabled and you want a different port, change it with systemctl edit ssh.socket and a ListenStream= override — not in sshd_config, where the setting will look right and do nothing.
Moving off port 22: theatre, but cheap theatre
Let us be honest about this one, because the internet is not. Moving sshd to port 2222 or 47000 stops zero competent attackers. A full TCP scan of one host takes seconds, the service announces itself in the banner, and anyone who has decided to attack you specifically will find it before finishing their coffee.
What it does do is cut your authentication log by something like ninety-five percent, and that has real value: it is the difference between logs you skim and logs you actually read. Signal you can see beats signal you have buried. If you monitor anything at all, a quiet auth.log is the single cheapest way to make an anomaly visible.
The costs are small but they are real, and they are the reason we call it optional rather than recommended. You will forget the port when you come back in eight months. A non-standard port above 1024 can, in principle, be claimed by an unprivileged process if sshd is ever not listening. Restrictive outbound firewalls on client networks block odd ports, so you will occasionally be unable to reach your own server from an office or a hotel. And on a socket-activated system you have to change it in the right place, per the previous section. Do it if quiet logs matter to you. Do not do it and then feel secure, and never do it instead of keys-only.
Default-deny, and the ruleset we actually run
A firewall that lists what to block is a filing system. A firewall that lists what to allow is a security control. The distinction is the entire game, because only the second one covers the service you install next month, the debug port you opened on a Friday, and the container that decided to expose itself.
On our platform you have two layers, and they are independent. The edge filter is optional, configured per server in the panel and enforced on the hypervisor, so blocked traffic never reaches your guest at all — useful for layer-4 rules you want applied before your kernel spends a cycle on them, and for keeping a compromised guest from being reachable while you work on it. The guest firewall is entirely yours: nftables, iptables, pf, whatever your image ships. We never touch it. Use both if the machine matters; use at least the second one always.
Two things in the ruleset in step 5 deserve explanation, because most copy-pasted rulesets get them wrong. Do not drop all ICMP. It feels tidy and it breaks path-MTU discovery, which produces the worst class of bug there is: small requests work, large responses hang, and nothing in your logs mentions the firewall. Accept at minimum destination-unreachable, time-exceeded and parameter-problem. Do not filter ICMPv6 by type unless you know the list. IPv6 relies on ICMPv6 for neighbour discovery and router advertisement; block it broadly and your IPv6 connectivity dies in a way that looks like a routing problem. Accepting all ICMPv6 on a single host is a reasonable trade and the ruleset below does exactly that.
One warning before you run flush ruleset: if Docker is installed, that line removes the NAT and filter rules Docker wrote, and container networking stops until systemctl restart docker puts them back. Load your ruleset first, restart Docker second, and read the next section before you publish a single container port.
The ports you did not know were open
Ask the machine what it is listening on. Not what you think you installed — what is bound right now:
ss -tulpn | grep -vE '127\.0\.0\.1|\[::1\]'Everything that survives that filter is reachable from somewhere other than the machine itself. On a stock image the usual finds are rpcbind on 111 (nothing you run needs it), an exim4 listener left over from the base install, and a systemd-resolved stub that is harmless on loopback and an open resolver if it is not. The dangerous ones arrive later, with software you installed on purpose: PostgreSQL on 0.0.0.0:5432 because a tutorial said to edit listen_addresses, Redis with no password because Redis had no password by default for most of its life, an Elasticsearch node, a Prometheus exporter, a Jupyter notebook. Every one of those has been the first step of a real breach many times over.
Now the gotcha that catches careful people. Docker does not ask your firewall for permission. When you write -p 5432:5432, Docker inserts DNAT rules in the nat table's PREROUTING chain, which the kernel evaluates before your input chain ever sees the packet; the traffic is then forwarded to the container. Your default-deny input policy is not consulted, ufw status shows the port closed, and the database is on the public internet. This is documented Docker behaviour, it has been this way for a decade, and it has exposed an enormous number of databases.
The fix is one string, and it is the habit worth building:
# wrong — reachable from the entire internet, whatever your firewall says
docker run -p 5432:5432 postgres
# right — bound to loopback, reachable only through an SSH tunnel or a proxy
docker run -p 127.0.0.1:5432:5432 postgresFinally, get an outside opinion. A port scan from the server tells you what the kernel thinks; a scan from somewhere else tells you what the world sees, which is the only number that matters. Run nmap -Pn -p- <your-ip> from your laptop, not from the machine, and compare the result with the list you expected.
Automatic updates: turn them on, and mean it
The window that gets small servers compromised is not the zero-day. It is the four weeks between a CVE going public with a working proof of concept and the next time you happen to log in. Attack tooling absorbs a new remote bug within days; a machine you patch “when you get to it” is exposed for the entire interval, and every honest operator knows how long that interval really is.
The objection to automatic updates is fear of breakage, and it is worth taking seriously — then narrowing. Restrict the automation to the security pocket of your distribution, where maintainers backport fixes into the packaged version rather than shipping new upstream releases. A Debian security update to openssl is a patched build of the same version you already run; the risk of it changing behaviour is far smaller than the risk of leaving a remote hole open for a month. Feature upgrades stay manual, where they belong.
The part people skip is the reboot. A patched libssl on disk does nothing for the process that mapped the old one at start-up, and a kernel update does nothing at all until you boot into it. Either accept an unattended reboot in a window you choose, or install needrestart and let it tell you which services are running against deleted libraries — but do one of them. “Automatic updates are on” while uptime reads 340 days is a comfortable illusion, and it is the most common one we see.
fail2ban, CrowdSec, or nothing at all
fail2ban reads your logs, notices repeated failures from an address and bans it for a while. CrowdSec does the same and shares the verdicts across a network of participants, so you can block an address that has misbehaved elsewhere before it reaches you. Both are good software. Neither is doing what most people think on a keys-only SSH server.
Once PasswordAuthentication is no, an SSH brute-force cannot succeed. Not “is unlikely to” — there is no code path. Banning an address after five failures therefore prevents nothing; it reduces log volume and a trivial amount of CPU. That is a real benefit, it is just not a security benefit, and the trade is not free: a badly written jail that watches the wrong log has locked more administrators out of their own servers than it has ever locked attackers out.
Where these tools genuinely earn their place is one layer up, on the services you actually expose: a login form, a WordPress admin, an API with rate limits, a mail server with SMTP AUTH. Those do accept passwords, they are brute-forceable, and a ban list is exactly the right control. So our verdict is narrow and specific. Skip the SSH jail. Put CrowdSec or fail2ban in front of the thing that has a password field. And whichever you choose, whitelist your own management address first — ignoreip exists for the evening you will otherwise remember for the wrong reasons.
Making the machine tell you when it changes
Prevention is what you can do in an hour. Detection is what tells you the hour was not enough. It does not need to be elaborate, and on a single server three cheap things cover most of the ground.
Keep the logs. On many images the journal lives in /run and evaporates at reboot, which means the record of an incident disappears in the reboot that follows it. Creating /var/log/journal is a one-line fix and the highest-value thing in this section.
Get told about logins. A line in /etc/ssh/sshrc that fires logger on every session start costs nothing and gives you a clean, greppable record separate from sshd's own chatter. One caveat that catches people: sshd runs ~/.ssh/rc instead of /etc/ssh/sshrc when the user has one, so the system-wide file is skipped for exactly the account most likely to have a customised dotfile. If you need a hook that cannot be shadowed, use pam_exec instead.
Know what the filesystem looked like on day one. AIDE records hashes of your binaries, libraries and unit files and reports what changed since. The catch is fundamental and usually ignored: a database stored on the machine it is watching can be regenerated by whoever compromised it, and then the check reports “no changes” forever. Copy the database off the box, or at minimum record its hash somewhere else, and the tool becomes worth its twenty minutes. Left in place, it is a comfort blanket.
The general principle is worth stating on its own: the log you cannot trust is the one on the compromised machine. Anything you genuinely intend to rely on — a journal, an integrity database, a backup — should have a copy somewhere the same attacker does not control. A second small server in another jurisdiction is a legitimate answer to that, and a five-dollar one is enough.
What this does not do
Everything above is perimeter work, and it is worth being precise about the boundary so you do not mistake a locked front door for a safe.
It does not protect data on a running machine. A hardened kernel and a closed firewall are irrelevant to anyone with hypervisor access, and to the contents of RAM while the machine is up. If your concern is the disk when the server is off, seized or decommissioned, that is disk encryption, and it is a different procedure with a different failure mode — see encrypting a VPS disk with LUKS.
It does not make you anonymous. A server can be flawlessly hardened and still announce who owns it, through a WHOIS record, a reused SSH key, an analytics tag, an SSH client that connects from home without a hop, or a certificate that ties two identities together. Paying with Monero and then logging in from your own address undoes the payment. That failure mode has its own page: the mistakes that deanonymize you.
It does not fix your application. An SQL injection, an unauthenticated admin endpoint or a dependency with a backdoor does not care about your sshd settings. Most compromises of well-run servers arrive through the port you opened deliberately, on purpose, to a service you wrote or installed.
It is not a backup. Ransomware, rm -rf with a variable that was empty, and a failed upgrade all end the same way. Take a copy, put it somewhere else, and restore from it once before you need to — the same discipline that makes a move to another host survivable makes a bad Tuesday survivable.
None of this is an argument against the hour. It is an argument for knowing exactly what the hour bought.
- Deploy with a key, never with a password
Generate the keypair on your own machine and paste the public half into the deploy form; the image will install it and you will never have a root password to lose. If you deploy from an image marked
cloud-init, you can pass the whole first-hour configuration as user-data instead — up to 64 KiB — and the machine comes up already hardened.# on your laptop, not on the server ssh-keygen -t ed25519 -a 100 -C "$(whoami)@laptop-cvh" cat ~/.ssh/id_ed25519.pub # paste this into the deploy formUse ed25519 unless something in your toolchain refuses it, in which case RSA at 4096 bits is fine. Protect the private key with a passphrase and load it into an agent; a key file with no passphrase on a laptop is a password written on the monitor. The same key set is injected into rescue mode, which is what makes step 4 recoverable.
- Open the second session before you touch anything
This is not optional and it is not paranoia. From here on you will change the daemon you are connected through and the firewall that lets you reach it. Keep one session open and idle as a rope back into the machine, and make every change in a different one. If a change is wrong, the open session is still authenticated and can undo it; a new connection would be refused.
# terminal A — the rope. Log in and leave it alone. ssh -i ~/.ssh/id_ed25519 root@<server-ip> # terminal B — where every command below runs. ssh -i ~/.ssh/id_ed25519 root@<server-ip>Test each change by opening a third connection, never by reconnecting the one you are working in. If the third connection fails, you still have two working shells and a problem instead of a crisis.
- Create the account you will actually use
Root over SSH is convenient for exactly one hour. After that, a named account with sudo gives you an audit trail, protects you from a mistyped command running with full privileges, and lets you disable a compromised account without disabling the machine.
adduser --disabled-password --gecos "" deploy install -d -m 0700 -o deploy -g deploy /home/deploy/.ssh cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys chown deploy:deploy /home/deploy/.ssh/authorized_keys chmod 0600 /home/deploy/.ssh/authorized_keys usermod -aG sudo deploy # a long random password, stored in your manager, used only by sudo passwd deploySet that password. A sudo account whose password was never set cannot use sudo, and discovering this after you have disabled root login is the classic way to lock yourself out with every file correct. Verify before moving on: from a new terminal,
ssh deploy@<server-ip>, thensudo -v. Both must work. - Write the SSH drop-in, then ask the daemon what it read
A numbered drop-in that sorts early beats whatever the cloud image shipped, per the ordering rule above. Write it, validate the syntax, read back the effective configuration, and only then reload.
cat > /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF' PermitRootLogin no PasswordAuthentication no KbdInteractiveAuthentication no PubkeyAuthentication yes AuthenticationMethods publickey PermitEmptyPasswords no AllowUsers deploy MaxAuthTries 3 LoginGraceTime 20 X11Forwarding no AllowAgentForwarding no AllowTcpForwarding no # remove this line if you use ssh -L / -D tunnels ClientAliveInterval 300 ClientAliveCountMax 2 EOF chmod 0644 /etc/ssh/sshd_config.d/10-hardening.conf sshd -t # silence means the syntax is valid sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|allowusers) ' systemctl reload sshRead that
sshd -Toutput before reloading, not after. It must saypermitrootlogin noandpasswordauthentication no. If it does not, your file is being overridden by one that sorts earlier —ls /etc/ssh/sshd_config.d/and rename yours lower. Now open a third terminal and log in asdeploy. Only when that works should you close the rope. - Load a default-deny firewall
nftables ships with both distributions and replaces the iptables ruleset with a single readable file. Adjust the accepted ports to the services you actually run — the list below assumes SSH and a web server, and nothing else.
apt install -y nftables cat > /etc/nftables.conf <<'EOF' #!/usr/sbin/nft -f flush ruleset table inet filter { chain input { type filter hook input priority filter; policy drop; ct state established,related accept ct state invalid drop iif lo accept # ICMP must live: dropping it blackholes path-MTU discovery. ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded, parameter-problem } accept ip6 nexthdr icmpv6 accept tcp dport 22 ct state new accept tcp dport { 80, 443 } ct state new accept limit rate 5/minute burst 5 packets log prefix "nft-drop: " level info } chain forward { type filter hook forward priority filter; policy drop; } chain output { type filter hook output priority filter; policy accept; } } EOF nft -c -f /etc/nftables.conf # check before you commit to it systemctl enable --now nftables nft list ruleset | head -40If Docker is installed,
flush rulesetalso removes Docker's rules: runsystemctl restart dockerafterwards and confirm your containers still answer. Then, from your laptop,nmap -Pn -p- <server-ip>and check that the open list matches the ports you just allowed — no more, and no fewer. - Turn on unattended security upgrades
Security pocket only, with a reboot window you have chosen rather than one you will keep postponing. Pick an hour that is quiet for your users and not on the hour, so you do not collide with everyone else's cron.
apt install -y unattended-upgrades needrestart cat > /etc/apt/apt.conf.d/51-cvh-auto <<'EOF' // Debian. On Ubuntu, replace the two Origins-Pattern lines with: // "${distro_id}:${distro_codename}-security"; Unattended-Upgrade::Origins-Pattern { "origin=Debian,codename=${distro_codename}-security,label=Debian-Security"; "origin=Debian,codename=${distro_codename},label=Debian-Security"; }; Unattended-Upgrade::Automatic-Reboot "true"; Unattended-Upgrade::Automatic-Reboot-WithUsers "false"; Unattended-Upgrade::Automatic-Reboot-Time "04:17"; Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; EOF unattended-upgrade --dry-run --debug | tail -25 systemctl status unattended-upgrades --no-pagerIf an unattended reboot is genuinely unacceptable, set it to
"false"and letneedrestart -breport which services are running against deleted libraries and whether a newer kernel is installed — then act on it. What is not acceptable is neither. - Close what is listening, and check from outside
Enumerate what is bound to something other than loopback, remove what you do not use, and confirm the result from a machine that is not this one.
ss -tulpn | grep -vE '127\.0\.0\.1|\[::1\]' # the usual leftovers on a base image systemctl disable --now rpcbind.socket rpcbind 2>/dev/null apt purge -y exim4-base exim4-config exim4-daemon-light 2>/dev/null apt autoremove --purge -y # what each running unit is allowed to reach systemd-analyze security --no-pager | head -20For anything that must keep running but needs no audience — a database, a cache, an admin panel, a metrics exporter — bind it to
127.0.0.1in its own configuration and reach it through an SSH tunnel:ssh -L 5432:127.0.0.1:5432 deploy@<server-ip>. That is strictly better than opening a port and hoping the service's own authentication is sound, and it is the pattern to reach for by default. - Record the baseline and arm the alarms
Ten minutes that only pay off on the day something is wrong — which is the day you will not be able to reconstruct any of it from memory.
# persistent journal, so a reboot stops erasing the evidence mkdir -p /var/log/journal systemd-tmpfiles --create --prefix /var/log/journal systemctl restart systemd-journald # a clean, greppable line per SSH session cat > /etc/ssh/sshrc <<'EOF' logger -t ssh-login "user=$USER from=${SSH_CONNECTION%% *} tty=${SSH_TTY:-none}" EOF # file-integrity baseline — then get the database off the machine apt install -y aide aideinit mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db sha256sum /var/lib/aide/aide.db # copy this hash somewhere else last -n 20 ; lastb -n 20 # who got in, and who triedFinish by writing down, off the server, four things: the SSH host key fingerprint from
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub, the AIDE hash above, the ports you deliberately opened, and where the private key lives. That note is what turns a bad morning into a checklist instead of an investigation.
The controls, ranked by what they actually buy you
| Control | What it stops | What it does not stop | Cost | Verdict |
|---|---|---|---|---|
| Keys-only SSH (PasswordAuthentication no) | Every credential-guessing bot on the internet, permanently and by construction | Anyone holding your private key, or a flaw in sshd itself | 5 minutes, once | Non-negotiable |
| Default-deny firewall (nftables) | Services you forgot were listening, and every one you install by accident later | Attacks against the ports you deliberately opened | 10 minutes, once | Non-negotiable |
| Unattended security upgrades | The n-day window — the weeks between a public exploit and your next login | Zero-days, and anything needing a reboot you never schedule | 5 minutes plus a reboot window | Non-negotiable |
| Named user with sudo instead of root | Mistyped commands with full privileges, and processes running as root for no reason | A local privilege escalation in the hands of someone already inside | 3 minutes | Worth it |
| File-integrity baseline (AIDE) | Silent edits to system binaries, libraries and unit files | Anything at all, if the database stays on the machine it watches | 20 minutes plus off-box storage | Worth it if the box matters |
| Moving SSH off port 22 | Roughly 95% of your authentication log volume | Anyone who runs a port scan, which is everyone who matters | 2 minutes, plus the evening you forget the port | Optional, for quiet logs |
| fail2ban / CrowdSec on sshd | Repeat offenders, and the CPU they waste on you | Nothing, once password authentication is off | 10 minutes, plus a genuine lockout risk | Skip on SSH; use on your app |
Questions worth answering
How long after deploy before the scanning starts?
Minutes, typically. Internet-wide scanners sweep the entire IPv4 space continuously, so a fresh address is reached on the next pass regardless of what runs on it. Assume you are being probed from the moment the machine answers its first packet — which is why the hardening happens in the first hour and not on the first weekend.
Do I still need fail2ban if password login is disabled?
On SSH, no. With PasswordAuthentication no there is no code path a brute-force can win, so banning after five failures prevents nothing — it reduces log noise, which is worth something but is not security. Where these tools do belong is in front of anything that genuinely accepts a password: a web login form, an admin panel, SMTP AUTH. If you install one, whitelist your own address first.
ufw, firewalld or nftables?
Any of them, as long as the default policy is drop and you understand what you deployed. ufw is the friendliest and writes nftables rules underneath on current Debian and Ubuntu. Plain nftables is one readable file you can diff and version-control, which is why we use it. The choice matters far less than the default policy — and none of the three changes the fact that Docker publishes ports underneath all of them.
Should I move SSH off port 22?
Only for quieter logs. It stops no competent attacker — a full scan takes seconds and the banner identifies the service. It does remove most of the noise from your authentication log, which makes real anomalies visible. Treat it as log hygiene, never as a control, and check whether your sshd is socket-activated before you change it: on Ubuntu 24.04 the Port directive in sshd_config is ignored and the port belongs to ssh.socket.
Will automatic updates break my site at four in the morning?
Restricted to the security pocket, very rarely. Those packages are backported fixes to the version you already run, not new upstream releases. The realistic risk is the reboot, not the patch — so choose the window yourself, set Automatic-Reboot-WithUsers to false so it waits while someone is logged in, and if the service truly cannot restart unattended, run needrestart -b on a schedule and act on what it reports. The one indefensible position is automatic updates with an uptime measured in years.
I locked myself out. What are my options?
Boot the server into rescue mode from the panel. It starts an Alpine environment in RAM with the same SSH keys as your account and your disk unmounted at /dev/vda, so you can mount it, fix the sshd drop-in or the firewall file, unmount and reboot. Nothing on the disk is touched by the rescue boot itself. The rescue host key fingerprint differs from your normal one — that is expected, not an interception.
Is running Lynis or a CIS hardening script instead of this a good idea?
As an audit, yes; as a substitute, no. Lynis is a genuinely useful second opinion and will find things this page does not mention. Automated CIS remediation scripts are a different proposition: they apply hundreds of changes designed for a corporate workstation fleet, several of which break a server in ways that are hard to trace weeks later. Do the hour by hand first, so you understand what your machine is doing, then run an auditor and read its findings one at a time.
Does hardening make my server anonymous?
No, and conflating the two is a common and expensive mistake. Hardening controls who can get in; anonymity controls who can tell it is yours. A perfectly locked-down server still leaks ownership through a WHOIS record, a reused SSH key, an analytics tag or an admin login from your home address. Paying with Monero and then connecting directly from your own connection undoes the payment entirely — that is covered in the mistakes that deanonymize you.
Keep exploring
Encrypt a VPS disk with LUKS
The other half of the job: this page closes the network, that one protects the disk when the machine is off.
Anonymity mistakes that deanonymize you
A flawlessly hardened server that still tells everyone who owns it. The failures are behavioural, not technical.
Set up a WireGuard VPS
The first service worth putting behind the firewall you just built — and a private door to everything you bound to localhost.
Deploy your offshore server.
Pick a region. Pick a plan. Paste a key. Pay. The next 47 seconds are on us.