How to Secure a VPS: 10 Minutes, an Hour, a Week
The reason VPS hardening guides don’t work isn’t that the advice is wrong. It’s that people intend to do it later, and later doesn’t arrive.
So this is organised by how much time you have right now — and every step comes with the command that proves it worked, because most of these steps fail silently.
First 10 minutes
Do this before you install anything. All four are quick and they close the doors that get walked through.
1. A user that isn’t root
adduser yourname
usermod -aG sudo yourname
Verify: groups yourname — the output must include sudo.
2. Key-based login
On your own machine, not the server:
ssh-keygen -t ed25519
ssh-copy-id yourname@your-server-ip
Verify: ssh yourname@your-server-ip logs you in with no password prompt. Do not continue until that works — the next step removes the fallback.
3. Turn off passwords and root login
Edit /etc/ssh/sshd_config (or better, drop a file into /etc/ssh/sshd_config.d/ — see the section below on why):
PasswordAuthentication no
PermitRootLogin no
Verify in two steps, and the first one matters. Check the syntax before you restart, because a malformed config plus a restart is how people lock themselves out:
sudo sshd -t
Silence means valid. Any output is an error you must fix first. Then restart and confirm what’s actually in effect:
sudo systemctl restart ssh
sudo sshd -T | grep -iE '^(permitrootlogin|passwordauthentication|pubkeyauthentication) '
You want:
permitrootlogin no
pubkeyauthentication yes
passwordauthentication no
Keep your existing session open and test a new one in a second window before you close anything.
4. Default-deny firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw enable
Verify: sudo ufw status verbose — the line you’re looking for is:
Default: deny (incoming), allow (outgoing), deny (routed)
If you run Docker, that firewall is weaker than it looks. Docker routes container traffic before ufw’s chains — its own documentation says this “effectively ignor[es] your firewall configuration.” Publish container ports to 127.0.0.1 explicitly, or use the DOCKER-USER chain. We go through the container side separately.
The trap that makes step 3 worth double-checking
This is the most useful thing in the article, and we found it by running the checks on a real hardened server rather than trusting them.
Modern Debian and Ubuntu images ship this in /etc/ssh/sshd_config:
Include /etc/ssh/sshd_config.d/*.conf
On the server we checked, that directory contained 50-cloud-init.conf and 99-hardening.conf. And here’s the result:
$ grep -iE '^\s*(PasswordAuthentication|PermitRootLogin)' /etc/ssh/sshd_config
(nothing)
$ sudo sshd -T | grep -iE '^(passwordauthentication|permitrootlogin) '
permitrootlogin no
passwordauthentication no
Grepping the main config file returns nothing, while the server is in fact correctly hardened. The directives live in an include.
Two ways this bites you:
You check and wrongly conclude it failed. You grep sshd_config, see nothing, and either panic or start adding duplicate settings.
You edit the main file and it silently does nothing. Include files are processed in order, so a directive in 99-hardening.conf wins over one you added to sshd_config. Cloud images frequently ship 50-cloud-init.conf with its own SSH settings — so on a fresh VPS, the file you’re editing may not be the file that decides.
The rule: sshd -T is the only authoritative answer, because it prints the effective configuration after every include and default has been resolved. Grepping a config file tells you what one file says. If you’re adding settings, put them in /etc/ssh/sshd_config.d/99-hardening.conf so they sort last and win.
First hour
5. fail2ban
sudo apt install fail2ban
sudo systemctl enable --now fail2ban
Verify: sudo fail2ban-client status lists the active jails, and per-jail detail shows whether it’s doing anything:
sudo fail2ban-client status sshd
On one real internet-facing server we checked, that jail reported 1,252 total failed attempts and 162 addresses banned, with three banned at that moment. That is what a normal, unremarkable server looks like — SSH brute-forcing is constant background weather, not a sign anyone has targeted you. It’s also why steps 2 and 3 matter more than fail2ban: with password authentication off, those 1,252 attempts had nothing to guess.
6. Automatic security updates
sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
Verify — and this one is genuinely easy to get wrong, because installing the package doesn’t guarantee it’s switched on:
cat /etc/apt/apt.conf.d/20auto-upgrades
Both lines must read "1":
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
Then confirm the timer is live and see what it would actually do:
systemctl is-enabled apt-daily-upgrade.timer
sudo unattended-upgrade --dry-run --debug
The dry run prints its allowed origins, which tells you it’s configured for security updates rather than sitting inert.
7. Audit what’s listening
ss -tulpn
Read the Local Address column, and treat anything bound to 0.0.0.0 or * as published to the internet. 127.0.0.1 is local-only and fine.
This is the check that catches the real mistakes — a database, a cache, an admin panel or an agent gateway that bound to every interface because that was its default. If you find one, fix the application’s bind address; a firewall rule on top of a service that shouldn’t be listening is two mistakes.
8. Turn off what you don’t use
systemctl list-units --type=service --state=running
Anything you can’t justify, disable. Fewer running services is fewer things to patch, and a default cloud image usually runs more than a single-purpose server needs.
First week
These take longer, they’re the ones everyone defers, and they’re the ones that matter when something actually goes wrong.
9. A backup you have restored
An untested backup is a belief, not a backup. Take one, then actually restore it — to a scratch server, a container, anywhere — and confirm the data is intact and you know the steps.
Verify: you have personally recovered a file from the backup. If you haven’t, you don’t have backups yet.
The details that bite: check the backup destination’s jurisdiction separately from your server’s, because they’re configured separately and a primary in Frankfurt with backups in Virginia is a cross-border transfer. And check whether your provider’s snapshots keep charging after you delete the server.
10. Log retention you chose on purpose
Your access logs contain IP addresses, and IP addresses are personal data. Almost every server keeps them forever by default with no retention policy and no documented basis, which is one of the most common quiet compliance failures on a self-managed box — we cover the GDPR side here.
Decide a period, then enforce it with logrotate.
Verify: sudo logrotate -d /etc/logrotate.conf parses your configuration in debug mode without applying it, so you can confirm the rules are being read before you rely on them.
11. A baseline, so you can tell what changed
Record what “normal” looks like now, while you know the machine is clean: the output of ss -tulpn, your running services, your installed package list, and a checksum of key config files. Keep it off the server.
Without a baseline, “has this been tampered with?” is unanswerable. With one, it’s a diff.
The quarterly self-audit
Re-run this every three months. It takes five minutes and it’s all read-only.
| # | Check | Command | Pass |
|---|---|---|---|
| 1 | SSH config valid | sudo sshd -t |
Silent |
| 2 | Root login off | sudo sshd -T | grep -i permitrootlogin |
no |
| 3 | Passwords off | sudo sshd -T | grep -i passwordauthentication |
no |
| 4 | Firewall default-deny | sudo ufw status verbose |
deny (incoming) |
| 5 | fail2ban running | sudo fail2ban-client status |
Jails listed |
| 6 | Auto-updates armed | cat /etc/apt/apt.conf.d/20auto-upgrades |
Both "1"
|
| 7 | Update timer live | systemctl is-enabled apt-daily-upgrade.timer |
enabled |
| 8 | Nothing unexpected listening | ss -tulpn |
No surprise 0.0.0.0
|
| 9 | Services justified | systemctl list-units --type=service --state=running |
All recognised |
| 10 | Restore tested this quarter | — | You did it |
Score out of 10. Anything below 8 is a machine you should not be running anything valuable on, and items 1–4 are non-negotiable.
One thing this audit can’t do is check itself from outside. A firewall you’ve only verified locally is still a belief — test from off the box as well.
Where to run it
Hostinger — the cheapest capable option, 4GB at $6.49 promotional and $11.99 at renewal, with one-click templates and a browser console that gets you back in if you do lock yourself out of SSH. That recovery path is worth more than it sounds while you’re doing step 3.
Hetzner — the best hardware per euro and a German company if jurisdiction matters. €19.99 for 4GB on the CPX line, plus €0.50/month for an IPv4 address; check availability on the cheaper tier first. Note it runs identity checks on new accounts, so open it before you need it.
DigitalOcean — the documentation choice at $24 for 4GB. Its guides on exactly this material are among the best written anywhere, which is a real part of what you’re paying for.
Cloudways — managed hosting on top of other people’s infrastructure, from $11 for 2GB with no term. The relevant point for this article: much of the above is done for you, which is the honest argument for managed hosting if you know you won’t run the quarterly audit.
ScalaHosting — runs its own SPanel rather than licensing cPanel and includes it, with managed security on its VPS plans. Worth a look if you want a control panel without a per-account licence fee eating your margins.
How we checked this
Every verification command in this article was run on a live Linux server on 18 August 2026, and the outputs quoted are real. That includes sshd -t returning silence on a valid config, sshd -T printing permitrootlogin no / pubkeyauthentication yes / passwordauthentication no, ufw status verbose printing Default: deny (incoming), allow (outgoing), deny (routed), fail2ban-client status listing an active sshd jail, ss -tulpn listing bound sockets, 20auto-upgrades containing both "1" values, systemctl is-enabled apt-daily-upgrade.timer returning enabled, and unattended-upgrade --dry-run --debug and logrotate -d both parsing configuration without applying changes.
The sshd_config include finding is the one we’d most want you to reproduce. On that server, /etc/ssh/sshd_config contained an Include /etc/ssh/sshd_config.d/*.conf directive, the directory held 50-cloud-init.conf and 99-hardening.conf, grepping the main file for PasswordAuthentication and PermitRootLogin returned nothing, and sshd -T reported both correctly set. That’s a demonstrated trap rather than a theoretical one.
We ran only read-only and dry-run commands. We did not enable ufw, install fail2ban, restart sshd or modify any configuration in the course of writing this — the mutating commands above are quoted from standard documentation and our own prior use, not executed for this article. So the adduser, usermod, ssh-copy-id, apt install and ufw enable lines are conventional and correct in form, but we verified the checks rather than the changes.
The fail2ban figures — 1,252 total failed attempts, 162 total bans, three active — are from a single real internet-facing server at one moment. They’re illustrative of the background rate, not a benchmark, and they’ll differ on any other machine. We haven’t published which server or the banned addresses.
What we did not do: test recovery from a lockout, attempt any intrusion, verify that any of these measures defeats a specific attack, or benchmark fail2ban’s effectiveness. This is a hygiene checklist, not a security assessment — the ordering reflects impact per minute spent, which is a judgement rather than a measurement. And step 11’s baseline advice is a practice we recommend rather than tooling we’ve tested here.
Provider pricing is each host’s published rates read across this series in August 2026 rather than re-verified today.
The host links above are affiliate links. Everything in the checklist is free and none of it requires a particular host — and the section pointing out that managed hosting does much of this for you argues for a product we’d earn less from than a bare VPS.
FAQ
What are the first things to do on a new VPS?
A non-root sudo user, key-based SSH, password and root login disabled, and a default-deny firewall. Those four take about ten minutes and close the doors that actually get walked through.
How do I check my SSH hardening actually applied?
sudo sshd -T and grep for permitrootlogin and passwordauthentication. That prints the effective configuration after all includes resolve — grepping sshd_config can return nothing on a correctly hardened server, because the directives are often in an include file.
Why does grepping sshd_config show nothing?
Because Debian and Ubuntu ship Include /etc/ssh/sshd_config.d/*.conf, and the real settings often live there — commonly in a cloud-init file or a hardening file. Those includes also override what you add to the main file, which is why edits sometimes appear to do nothing.
Is fail2ban necessary if I use SSH keys?
It’s useful but secondary. With password authentication off, brute-force attempts have nothing to guess — one server we checked had logged 1,252 failed attempts with no risk attached. fail2ban mainly reduces noise and load.
How do I know what my server is exposing?
ss -tulpn, then treat anything bound to 0.0.0.0 or * as public. Fix it at the application’s bind address rather than papering over it with a firewall rule, and verify from off the machine as well.
Do I need to test my backups?
Yes, and until you’ve restored one you don’t have backups. Also check the backup destination’s jurisdiction separately from the server’s, since they’re configured independently.
How often should I re-check all this?
Quarterly, using the ten-point audit above. It’s read-only and takes about five minutes; the value is catching the thing that changed when you upgraded something else.