Every self-hosted project I run starts the same way: a brand new VPS and about twenty minutes of setup before I install a single application. That twenty minutes is what separates "my server" from "someone else's crypto miner." A fresh box with a public IP starts getting probed within minutes, and the default configuration on most images is built for convenience, not safety.
This is the secure baseline I set up on every new server, before Docker, before n8n, before anything else. It is also the starting point our production n8n guide assumes you already have. Every command below was checked against current Ubuntu LTS documentation, and I flag the parts that genuinely need a real server to verify.
Key takeaways
- Never do daily work as root. Create a sudo user and log in as that instead.
- Use an SSH key and turn password login off, but only after you confirm the key works.
- Deny everything at the firewall by default, then open only the ports you actually use.
- Turn on automatic security updates so patches land while you sleep.
- If you plan to run Docker, remember that published ports skip UFW. Bind them to
127.0.0.1.
Prerequisites
- A VPS running a current Ubuntu LTS. Both 24.04 "Noble Numbat" and 26.04 "Resolute Raccoon" work well. I run long-lived boxes on Hostinger VPS hosting, which is also what powers the n8n guide.
- An SSH key pair on your own machine. If you do not have one yet, Step 3 creates it.
- A terminal, and a note of your provider's recovery console. Most hosts, Hostinger included, give you a browser based console in their control panel. That is your way back in if you ever lock yourself out, so find it before you start.
Disclosure: some links in this guide, including the Hostinger link above, are referral or affiliate links. If you sign up through them we may earn account credit or a commission, at no extra cost to you. We only point at tools we actually run.
Step 1: Log in and update the system
Right after the server boots, log in with the credentials your provider gave you and bring every package up to date:
ssh root@YOUR_SERVER_IP
apt update && apt upgrade -y
If the upgrade pulls a new kernel, reboot with reboot and log back in. Starting from a fully patched system means the rest of this guide is the only thing left between you and a solid baseline.
Step 2: Create a non-root user
Working as root all day is the single most common mistake on a new server. One typo or one bad script runs with full control of the machine. Create a normal user with sudo rights and use that from now on:
adduser deploy
usermod -aG sudo deploy
Swap deploy for whatever name you like. The adduser command asks for a password; pick a strong one, since sudo will ask for it.
Step 3: Set up SSH keys
From your own machine, not the server, create a key if you do not already have one:
ssh-keygen -t ed25519 -C "you@your-machine"
Copy the public half up to the new user, then log in as that user to confirm it works:
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@YOUR_SERVER_IP
ssh deploy@YOUR_SERVER_IP
Do not move on until that last command logs you in without asking for the account password. The next step turns password login off completely, and if the key is not working you will lock yourself out.
Step 4: Harden SSH
Now lock SSH down to keys only and stop root from logging in over it. On Ubuntu, the clean way is a drop-in file, so a future package update cannot quietly overwrite your changes. As the deploy user:
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf > /dev/null << 'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
EOF
There is one catch that trips people up. SSH reads its settings top to bottom and keeps the first value it finds for each one, and cloud images ship a lower numbered file that sets PasswordAuthentication for you. On Ubuntu 24.04 it is 60-cloudimg-settings.conf, which already sets it to no; older images used 50-cloud-init.conf and sometimes set it to yes. Because a lower numbered file loads first, whatever it says wins over your file. So do not trust the filename alone. Check the effective configuration, which is the real source of truth:
sudo sshd -t # tests syntax; no output means it is fine
sudo sshd -T | grep -E "permitrootlogin|passwordauthentication|pubkeyauthentication"
You want to see passwordauthentication no and permitrootlogin no in that output. If password auth still says yes, open the offending lower numbered file, comment that line out, and check again. Once it reads correctly, reload SSH (this does not drop your current session):
sudo systemctl reload ssh
Keep your existing terminal open and log in from a second one to be sure. If anything is wrong, the open session is your safety net.
Step 5: Turn on a firewall
UFW is a friendly front end to the kernel firewall. It is present on the desktop and full server images, but a minimal cloud image often does not include it, so install it first (a no-op if it is already there). Then set it to deny everything coming in, allow your own traffic out, and open only SSH and the web ports:
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable
sudo ufw status verbose
If you are not serving a website yet, skip the 80,443 line and add it later. The whole idea is that nothing is reachable unless you said so.
Step 6: Automatic security updates
Security patches are only useful once they are installed. The unattended-upgrades package applies them for you:
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
Choose "Yes" at the prompt. You can preview what it would do without changing anything:
sudo unattended-upgrades --dry-run --debug
By default it installs security updates only, which is the sweet spot: you stay patched without surprise changes to everything else on the box.
Step 7: (Optional) Add fail2ban
With password login already off, brute force attempts against SSH are mostly noise, since there is no password to guess. If you still want to trim the log spam, fail2ban watches for repeated failures and bans the source for a while:
sudo apt install fail2ban -y
Its defaults enable an SSH jail out of the box. Treat this as a nicety, not a substitute for keys and a firewall.
The Docker trap almost everyone hits
Here is the one that surprises even experienced people, and it is why it belongs in the baseline.
Docker bypasses UFW. When you publish a container port, Docker writes its own firewall rules that skip UFW entirely. Your
ufw statuscan look locked down while a database sits wide open to the internet.
The reason is where each tool sits in the network path. Docker sends published-port traffic through its own chain before it ever reaches the point UFW inspects, so UFW never gets a say. This is documented behavior, described in Docker's own packet filtering and firewalls page and flagged by the OWASP Docker Security Cheat Sheet.
The simplest, most reliable fix is to bind published ports to localhost instead of every interface. Compare:
# Exposed to the whole internet, even with UFW "on":
ports:
- "5432:5432"
# Reachable only from the server itself:
ports:
- "127.0.0.1:5432:5432"
Better still, do not publish internal services at all. Containers on the same Docker network reach each other by name, so a database that only your app talks to needs no host port. That is exactly the pattern in our n8n guide, where Postgres is never published and only Caddy faces the internet.
Where to go next
You now have a server that is patched, key-only, firewalled, and no longer running as root. Two natural next steps:
- Take SSH off the public internet entirely. In the next guide I put SSH behind a private Tailscale network and close port 22 to the world: Put SSH behind Tailscale and close port 22.
- Run something on it. Our production n8n guide uses this exact baseline as its foundation.
An honest word on trade-offs
None of this makes a server "unhackable," and anyone who tells you a checklist does is selling something. What it does is remove the easy wins: default passwords, root over SSH, exposed services, unpatched holes. That covers the overwhelming majority of automated attacks, which is what actually hits a small VPS. Keep your software updated, keep backups you have tested, and add depth (like the Tailscale step) as your setup grows.
Final thoughts
The shape of this never really changes: a real user, keys not passwords, a default-deny firewall, automatic patches, and an awareness of how Docker treats ports. Do it once, turn it into muscle memory, and every future box takes ten minutes. Then you get to the fun part, which is running your own software.
Verified on a real Ubuntu 24.04.4 server with Docker 29.1.3: the SSH hardening effective values (sshd -T), the default-deny UFW policy, the Docker-bypasses-UFW behavior and the 127.0.0.1 fix (an external request reached the 0.0.0.0-published port straight through an active firewall, then was refused once the port was bound to loopback), automatic security updates, and the fail2ban SSH jail. See the linked lab notes.
Top comments (0)