2026.06 / SECURITY CHECKLIST 017
A Practical VPS Hardening Checklist for Self-Hosted Apps
The first time you run your own server, the default install is tuned for convenience, not for exposure. A fresh VPS from Hetzner, DigitalOcean, OVH, or Linode is usually reachable from the whole internet within seconds of booting, and SSH password logins are often on by default. That is fine for the first five minutes. It is not fine for the next five years.
This is the hardening checklist I actually run against a self-hosted box before it ships a single app. It is deliberately ordered: each step is safer to do after the previous one, and several steps exist specifically to stop you locking yourself out of your own machine.
Goal: shrink the attack surface, stop automated brute force, keep the box patched, and make sure a single mistake does not cost you the data.
It is not a compliance document and it is not the CIS Benchmarks. Think of it as the minimum sane posture for a one-box self-hosted setup running things like Coolify, a self-hosted mail server, and a handful of small web apps.
0. Before you touch SSH: keep a second session open
Every step below that edits sshd_config or your firewall has the power to lock you out. Before you change anything, open a second SSH session in a second terminal and leave it logged in. If your edited config breaks the first session, the second one is still alive and you can fix it without a recovery console.
Also keep your hosting provider's out-of-band console (Hetzner Console, DigitalOcean Recovery, Vultr Web Console) bookmarked. That is the recovery parachute when SSH itself is broken.
1. Update everything, then turn on automatic security updates
On the first boot, bring the system current:
sudo apt update && sudo apt upgrade -y
sudo apt install -y unattended-upgrades
On Debian and Ubuntu, unattended-upgrades can pull in security fixes automatically. The package reads /etc/apt/apt.conf.d/50unattended-upgrades for what it is allowed to install. A sensible default is to allow the security origin and leave third-party repos on manual, so a surprise kernel upgrade from a PPA does not silently reboot an app mid-traffic.
sudo dpkg-reconfigure -plow unattended-upgrades
Automatic reboots are a separate choice. I let security patches install on their own but schedule reboots deliberately, because a kernel update that reboots a server at 03:00 can take down a live site at the wrong moment. Know which behaviour you have.
2. Create a non-root user with sudo
Running everything as root means a typo can delete the whole filesystem and a compromise is immediately maximal. Make a normal user, give it passwordless sudo, and switch to it:
adduser amaria
usermod -aG sudo amaria
Copy your SSH public key into the new user's ~/.ssh/authorized_keys before you disable root login, then log in as that user in a fresh session to confirm sudo works. Only then proceed.
3. SSH keys only, no passwords, no root login
Generate a key on your laptop, not on the server:
ssh-keygen -t ed25519 -C "amaria@laptop-2026"
Push it to the server with ssh-copy-id or by hand. Then edit /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
MaxAuthTries 3
LoginGraceTime 20
Reload sshd and verify in a new session before closing the old one:
sudo sshd -t && sudo systemctl reload ssh
sshd -t checks the syntax of the config without applying it. Never skip it. A typo in sshd_config is the most common way to lock yourself out.
Should you change the SSH port? It cuts down log noise from opportunistic scanners, but it is security through obscurity and not a real defence. I do it on noisy boxes and leave it alone on quiet ones. Either way, it is not a substitute for the key-only step above.
4. Turn the firewall on, smallest hole first
ufw is the simplest sane default on Ubuntu. The order matters: allow SSH before you enable the firewall, or you will lock yourself out again.
sudo ufw allow OpenSSH # or: sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
For a self-hosted web stack, the public surface should usually be exactly three things: SSH, HTTP, and HTTPS. Everything else — databases, admin panels, Coolify's dashboard, mail admin ports — belongs on the loopback interface or behind a tunnel, not on a public port.
If you change the SSH port, allow that port instead of OpenSSH, and if you run a mail server you will additionally need 25, 465, 587, 993 depending on what you accept. Add those explicitly and deliberately; do not blanket-allow.
5. Stop brute force with fail2ban
Key-only logins make password brute force mostly irrelevant, but bots will still hammer SSH and fill your logs. fail2ban watches auth logs and temporarily bans IPs that fail too many times.
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
The default sshd jail is reasonable for a small box: 10 minutes, a handful of retries. If you changed the SSH port, tell fail2ban about it in /etc/fail2ban/jail.local so it watches the right log lines. fail2ban is also useful for protecting mail and web apps — there are ready-made jails for Postfix, Dovecot, and nginx.
6. Add swap, especially on small VPS plans
Small VPS plans ship with 1–2 GB of RAM. Without swap, a memory spike from a build or a misbehaving container will trigger the OOM killer and take down apps unpredictably. A simple 2–4 GB swapfile smooths that out:
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Swap is not a replacement for enough RAM. It is a safety net so a transient spike does not kill the whole box. Tune vm.swappiness down (around 10) on a server so the kernel prefers real memory.
7. Set the timezone and keep the clock honest
A consistent timezone makes logs readable across machines, and an accurate clock is a hard requirement for TLS, Kerberos, and some mail deliverability checks. UTC is the sane default for server logs:
sudo timedatectl set-timezone UTC
sudo timedatectl set-ntp true
timedatectl
Store timestamps in UTC in your database and convert to local time only at the display edge. You will thank yourself the first time you debug an incident across two services in different regions.
8. Tighten the network stack with sysctl
The kernel has sensible defaults for a desktop and looser defaults than a public server wants. A small /etc/sysctl.d/99-hardening.conf file covers the high-value items:
# Drop spoofed / martian packets
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Log and drop packets we do not have a route for
net.ipv4.conf.all.log_martians = 1
# Ignore ICMP broadcast / redirect noise
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
# Disable source packet routing
net.ipv4.conf.all.accept_source_route = 0
# SYN flood mitigation
net.ipv4.tcp_syncookies = 1
Apply with sudo sysctl --system and check for errors. None of these will break a normal web stack; they just close old attack categories that are still scanned for constantly.
9. Bind admin services to loopback, proxy the rest
This is the step that does the most work for the least effort. Most self-hosted disasters come from an admin interface that was accidentally left on a public port.
The pattern: run Coolify's dashboard, your database, Redis, admin tools, and anything with a login on 127.0.0.1 or a private Docker network, and expose only the public apps through a reverse proxy on 80/443. Traefik (which Coolify ships with) and nginx both do this well.
If you need to reach an admin UI from outside your home, do not just open port 8000 to the world. Put it behind a Cloudflare Tunnel plus Cloudflare Access policy, or a WireGuard tunnel, and verify the login page works through that path before you close the raw port. My Cloudflare DNS checklist covers the related question of which records to proxy and which to keep DNS-only.
10. Backups you have actually restored from
A backup you have never restored is a rumour. For a one-box setup the realistic plan is:
- Snapshot the whole VPS on a schedule at the hosting provider — cheap, coarse, and good for "I deleted the wrong thing".
- Export app data (Postgres dumps, Docker volumes, mail config, Coolify database) on a schedule and copy them off the box to object storage or a different provider.
- Test a restore into a throwaway VPS at least once. The first time you need a backup is the wrong time to learn the dump format is wrong.
Encrypt off-box backups and keep the decryption key somewhere that is not the same box. A backup and its key on the same compromised server is not a backup.
11. Know what is listening
After everything is running, confirm what is actually exposed. The two commands I check on any new box:
sudo ss -tulpn # what is listening locally
sudo ufw status numbered # what the firewall allows
If ss shows a database or admin port on 0.0.0.0 instead of 127.0.0.1, that is the bug to fix before anything else. From outside, an nmap scan against your own server (from a different machine, with permission) confirms the public surface really is just the ports you intended.
12. Logging and a basic change record
You do not need a SIEM for one box, but you do need to be able to answer "what changed last night?". Keep it simple:
- Leave
journaldorrsyslogon with reasonable retention. - Install
auditdif you want a record of who ran privileged commands. - Mirror your hardening config (the
sysctlfile,sshd_config,ufw rules,fail2banjails) into a private Git repo so changes are versioned and reviewable.
Version-controlling your server config is the single biggest upgrade from "it works on my box" to "I can rebuild this box".
The one-page checklist
- Update the system; enable unattended security upgrades.
- Create a non-root sudo user; copy your SSH key in.
- Disable root login and password auth; reload sshd with
sshd -tfirst. - Enable UFW: allow SSH, 80, 443 (and mail ports only if needed).
- Install fail2ban for SSH (and mail/web jails as you add services).
- Add a swapfile; tune swappiness down.
- Set timezone to UTC; turn on NTP.
- Apply the sysctl hardening block.
- Bind admin services to loopback; proxy public apps on 80/443 only.
- Schedule VPS snapshots plus off-box, encrypted app backups; test a restore.
- Audit
ss -tulpnandufw status; nothing on0.0.0.0that shouldn't be. - Version-control the hardening config in a private repo.
None of this is exotic. The whole point is that hardening is not a clever trick; it is a short list of unglamorous defaults applied in the right order, then checked. Do the boring steps, keep a second SSH session open while you do them, and verify the public surface afterwards. That is most of the security you will ever get from a single well-run box.