This is the first article in a four-part series where I document how I turned a 10€/month VPS into a production-grade platform hosting my portfolio, a university group webapplication and a SaaS product, all isolated from each other with Kubernetes. In this part, we take a fresh Ubuntu server and lock it down properly before installing anything else.
Why bother with hardening?
The moment your VPS gets a public IP address, it starts receiving attacks. Not "might receive", it starts. Within minutes, automated bots will probe port 22, try root:root, admin:admin123 and thousands of other credential combinations. If you skip this step and jump straight to deploying your apps, you are building on sand.
The good news: an hour of work is enough to eliminate the vast majority of these threats. Here is what we will set up:
- A non-root user with sudo privileges
- SSH key authentication, with passwords and root login disabled
- UFW as a simple, effective firewall
- Fail2ban to ban brute-force attackers automatically
- Automatic security updates
What you need
- A fresh VPS running Ubuntu 24.04 LTS or newer. I use a Hostinger KVM 2 (2 vCPU, 8 GB RAM, 100 GB NVMe), but any provider works: Hetzner, DigitalOcean, OVH, Contabo.
- The root password or SSH key your provider gave you.
- A terminal on your local machine (macOS, Linux, or WSL on Windows).
Throughout this tutorial, replace YOUR_SERVER_IP with your server's IP address and deploy with the username you want to use.
Step 1: First login and system update
Connect as root for the first and last time:
ssh root@YOUR_SERVER_IP
Update everything before touching anything else:
apt update && apt upgrade -y
apt autoremove -y
If a kernel update was installed, reboot now:
reboot
Wait a minute, then reconnect.
Step 2: Create a non-root user
Working as root is like driving without a seatbelt: fine until it isn't. One mistyped rm -rf and the party is over. Create a dedicated user:
adduser deploy
Choose a strong password (you will still need it for sudo, even after we disable password SSH logins). Then grant sudo privileges:
usermod -aG sudo deploy
Verify it works:
su - deploy
sudo whoami
# Expected output: root
Step 3: SSH key authentication
Passwords can be brute-forced. SSH keys, in practice, cannot. If you don't have a key pair yet, generate one on your local machine (not on the server):
ssh-keygen -t ed25519 -C "deploy@YOUR_SERVER_IP"
Ed25519 is the modern default: shorter keys, faster, and at least as secure as 4096-bit RSA.
Now copy the public key to the server:
ssh-copy-id deploy@YOUR_SERVER_IP
Test it from your local machine:
ssh deploy@YOUR_SERVER_IP
If you log in without being asked for a password, the key works. Do not proceed to the next step until this works, otherwise you will lock yourself out.
Step 4: Harden the SSH daemon
Now we disable everything an attacker could exploit. Open the SSH configuration:
sudo nano /etc/ssh/sshd_config
Find and change (or add) the following lines:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
A quick explanation of each:
-
PermitRootLogin no: root can no longer log in over SSH at all. Attackers love the root account because they don't need to guess the username. -
PasswordAuthentication no: keys only. Brute-force attacks become pointless. -
MaxAuthTries 3: three failed attempts and the connection is dropped. -
ClientAliveInterval/ClientAliveCountMax: idle sessions are closed after 10 minutes. -
X11Forwarding no: we are running a server, not a remote desktop.
On Ubuntu 24.04+, also check the drop-in directory, because cloud providers often override settings there:
ls /etc/ssh/sshd_config.d/
If you see a file like 50-cloud-init.conf containing PasswordAuthentication yes, edit or remove it, otherwise it silently wins over your main config.
Validate the configuration and restart the daemon:
sudo sshd -t
sudo systemctl restart ssh
Keep your current session open and test the new rules from a second terminal:
ssh deploy@YOUR_SERVER_IP # should work (key)
ssh root@YOUR_SERVER_IP # should be refused
Step 5: Firewall with UFW
UFW (Uncomplicated Firewall) lives up to its name. The strategy is simple: deny everything inbound, then open only what we need.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
The order matters: allow OpenSSH before enabling the firewall, or you will cut the branch you are sitting on. Then:
sudo ufw enable
sudo ufw status verbose
Expected output:
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
OpenSSH ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
That's it. Ports 80 and 443 will later be handled by our reverse proxy (Traefik, in Part 2). Everything else, including databases, monitoring dashboards and internal services, stays invisible from the internet.
Step 6: Fail2ban
Even with keys-only authentication, bots will keep hammering your SSH port and polluting your logs. Fail2ban watches the logs and bans IPs that misbehave.
sudo apt install fail2ban -y
Never edit jail.conf directly (it gets overwritten on updates). Create a local override instead:
sudo nano /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
bantime = 24h
maxretry = 3
Translation: any IP that fails SSH authentication 3 times within 10 minutes is banned for 24 hours. Enable and start the service:
sudo systemctl enable fail2ban
sudo systemctl restart fail2ban
Check that the SSH jail is active:
sudo fail2ban-client status sshd
Come back after a day and run that command again. Seeing the list of banned IPs grow is oddly satisfying, and a good reminder of why this whole article exists.
Step 7: Automatic security updates
A hardened server that runs unpatched software is only hardened on paper. Ubuntu's unattended-upgrades installs security patches automatically:
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades
Select "Yes" in the dialog. To verify the configuration:
cat /etc/apt/apt.conf.d/20auto-upgrades
You should see:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
By default, only the security repository is enabled for automatic upgrades, which is exactly what we want on a server: security patches yes, surprise major version bumps no.
Final checklist
Before moving on, verify each item:
# 1. Root login refused
ssh root@YOUR_SERVER_IP
# Expected: Permission denied
# 2. Password auth refused (from a machine without your key)
ssh -o PubkeyAuthentication=no deploy@YOUR_SERVER_IP
# Expected: Permission denied (publickey)
# 3. Firewall active
sudo ufw status | head -1
# Expected: Status: active
# 4. Fail2ban running
sudo systemctl is-active fail2ban
# Expected: active
# 5. Auto-updates configured
sudo unattended-upgrade --dry-run --debug 2>&1 | tail -5
If all five checks pass, congratulations: your VPS just went from "free real estate for botnets" to a solid foundation.
What's next
In Part 2, we install the actual platform: Docker, K3s (a lightweight Kubernetes distribution that fits comfortably in 8 GB of RAM), Traefik v3 as reverse proxy, and cert-manager for automatic Let's Encrypt certificates. By the end of it, any app we deploy will get HTTPS with zero manual certificate handling.
Top comments (0)