DEV Community

adeutou
adeutou

Posted on

Self-Hosting Like a Pro, Part 1: Hardening a Fresh Ubuntu VPS

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:

  1. A non-root user with sudo privileges
  2. SSH key authentication, with passwords and root login disabled
  3. UFW as a simple, effective firewall
  4. Fail2ban to ban brute-force attackers automatically
  5. 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
Enter fullscreen mode Exit fullscreen mode

Update everything before touching anything else:

apt update && apt upgrade -y
apt autoremove -y
Enter fullscreen mode Exit fullscreen mode

If a kernel update was installed, reboot now:

reboot
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verify it works:

su - deploy
sudo whoami
# Expected output: root
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Test it from your local machine:

ssh deploy@YOUR_SERVER_IP
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Find and change (or add) the following lines:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Never edit jail.conf directly (it gets overwritten on updates). Create a local override instead:

sudo nano /etc/fail2ban/jail.local
Enter fullscreen mode Exit fullscreen mode
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled = true
bantime = 24h
maxretry = 3
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check that the SSH jail is active:

sudo fail2ban-client status sshd
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Select "Yes" in the dialog. To verify the configuration:

cat /etc/apt/apt.conf.d/20auto-upgrades
Enter fullscreen mode Exit fullscreen mode

You should see:

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)