Most tutorials on how to set up a Linux VPS stop at "log in as root and install stuff." That's how you end up with a compromised server three weeks later, wondering why your CPU is pegged at 100% mining Monero for someone in Romania.
This guide does it properly. You'll go from a freshly provisioned VPS to a server that has a non-root deploy user, hardened SSH, a working firewall, and swap configured — all before you install a single application. I'm running this exact setup across a fleet of Hetzner CAX11 and CX22 instances (€4–€5/month as of mid-2025), and it hasn't let me down.
Expect to spend about 25–30 minutes the first time. After that, you'll do it from memory in 10.
Pick Your VPS and OS
I'm going to assume you've already got a VPS provisioned. If you haven't, Hetzner is my default recommendation — the price-to-performance ratio is hard to beat for indie hackers. A CX22 (2 vCPU, 4 GB RAM) runs €4.35/month and handles most side projects comfortably.
For the OS, pick Ubuntu 24.04 LTS. It's boring. That's the point. Long support window, massive community, and every tool you'll ever need has Ubuntu install instructions. Debian 12 is a fine alternative if you prefer it — the steps below are identical.
When your provider asks about SSH keys during provisioning, add yours. If you haven't generated one yet:
ssh-keygen -t ed25519 -C "your@email.com"
Copy ~/.ssh/id_ed25519.pub into the provider's SSH key field. Never skip this step — password-only root login is the first thing bots probe.
First Login and Immediate Housekeeping
SSH in as root:
ssh root@YOUR_SERVER_IP
First thing: update everything. Fresh images from providers are often weeks behind on patches.
apt update && apt upgrade -y
Set the hostname while you're at it. I name servers after what they run — api-prod, worker-01, whatever makes sense to you.
hostnamectl set-hostname your-hostname
Set the timezone to UTC. Always UTC on servers — local time on servers causes log confusion across regions.
timedatectl set-timezone UTC
Create a Non-Root Deploy User
Running everything as root is a bad habit. One typo with rm -rf and your day is ruined. Create a dedicated user:
adduser deploy
Give it sudo access:
usermod -aG sudo deploy
Now copy your SSH key to the new user so you can log in without a password:
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
Open a second terminal and test the login before you do anything else:
ssh deploy@YOUR_SERVER_IP
If that works, you're ready to lock down SSH. If it doesn't, fix it now — you still have root access in the first terminal.
Harden SSH
This is the step most tutorials skip or rush. Edit the SSH daemon config:
sudo nano /etc/ssh/sshd_config
Find and change (or add) these lines:
Port 22
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthorizationKeysFile .ssh/authorized_keys
X11Forwarding no
AllowUsers deploy
A word on changing the SSH port: some people move it to something like 2222 to reduce noise in auth logs. It does cut down on bot noise, but it's security through obscurity — not a real defense. I leave it on 22 and let fail2ban handle the noise. Your call.
Restart SSH:
sudo systemctl restart ssh
Test again from a new terminal before closing your existing sessions. If you lock yourself out of a Hetzner VPS, you can recover via the web console — but it's annoying.
Set Up the Firewall with UFW
UFW (Uncomplicated Firewall) is exactly what the name says. It wraps iptables in something a human can actually use.
sudo apt install ufw -y
Set default policies first — deny everything in, allow everything out:
sudo ufw default deny incoming
sudo ufw default allow outgoing
Allow SSH (do this before enabling UFW, or you'll lock yourself out):
sudo ufw allow ssh
If you changed the SSH port to 2222, use sudo ufw allow 2222/tcp instead.
Allow HTTP and HTTPS if you're running a web server:
sudo ufw allow http
sudo ufw allow https
Enable it:
sudo ufw enable
Verify:
sudo ufw status verbose
You should see something like:
Status: active
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
Only open ports you actually need. Every open port is an attack surface.
Install fail2ban
Even with password auth disabled, you'll still get hammered by bots attempting logins. fail2ban watches your auth logs and bans IPs that fail too many times. It won't stop a determined attacker, but it keeps your logs clean and reduces noise.
sudo apt install fail2ban -y
Create a local config file (never edit the default — it gets overwritten on updates):
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
Find the [sshd] section and make sure it looks like this:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 5
bantime = 3600
findtime = 600
This bans any IP that fails 5 times within 10 minutes, for 1 hour. Adjust to taste.
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
Check it's running:
sudo fail2ban-client status sshd
Configure Swap
Small VPS instances (1–2 GB RAM) will OOM-kill your processes under load without swap. Even on 4 GB instances, I always add swap — it's cheap insurance.
Check if you already have swap:
swapon --show
If it's empty, create a 2 GB swap file:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
Make it permanent across reboots:
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Tune swappiness. The default is 60, which is too aggressive for a server — it'll start swapping when you still have plenty of RAM free.
sudo sysctl vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
Verify:
free -h
You should see your swap listed under the Swap: row.
Enable Automatic Security Updates
You're not going to remember to run apt upgrade every week. Set up unattended-upgrades to handle security patches automatically:
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
Answer yes when prompted. This only applies security updates — it won't auto-upgrade your Node.js or PostgreSQL versions and break your app.
Check the config if you want to tune it:
cat /etc/apt/apt.conf.d/50unattended-upgrades
The defaults are sensible. Leave them alone unless you have a specific reason to change something.
Quick Comparison: What Each Step Actually Protects
| Step | What it mitigates | Cost |
|---|---|---|
| SSH keys only | Brute-force password attacks | Free |
| Disable root login | Direct root compromise | Free |
| UFW firewall | Exposed ports, unintended services | Free |
| fail2ban | Login flood / bot noise | Free |
| Swap configured | OOM kills under load | Free |
| Unattended upgrades | Unpatched CVEs | Free |
Every single item on this list is free and takes under 5 minutes. There's no excuse for skipping any of them.
Optional But Recommended: Install Useful Tools
A few tools I install on every server before I do anything application-specific:
sudo apt install -y \
htop \
curl \
wget \
git \
unzip \
ncdu \
jq \
ufw \
net-tools
- htop — better than top for watching CPU/memory
- ncdu — disk usage analyzer, saves you when you're hunting down what ate your disk
- jq — JSON processor, essential if you're working with APIs
- net-tools — gives you
netstat, useful for checking what's listening on what port
None of these are mandatory, but you'll thank yourself later.
What's Next
At this point you have a properly hardened base. What you install on top depends on what you're building — a Node.js app, a Django service, a Postgres database, a reverse proxy with Caddy or Nginx. The foundation is the same regardless.
If you're running multiple services on one VPS, check out how to set up Caddy as a reverse proxy — it handles HTTPS automatically and the config syntax won't make you want to quit programming.
For teams or projects where you're deploying frequently, look at automating deployments with GitHub Actions and a self-hosted runner — it pairs well with this setup.
The Bottom Line
Knowing how to set up a Linux VPS properly isn't optional if you're self-hosting anything real. The steps above — create a deploy user, harden SSH, configure UFW, install fail2ban, set up swap, enable auto security updates — take 30 minutes once and protect you indefinitely.
Do it tonight. Provision a cheap VPS, run through this guide, and you'll have a template you can replicate in 10 minutes for every future project.
If you want to save the commands as a shell script you can reuse, that's a natural next step — I'll cover bootstrap scripts in a future post. Subscribe to the newsletter if you want a heads-up when it's out.