Setting Up a Hetzner VPS: Provisioning, Firewalls, and Locking It Down
This post covers how I set up and hardened the VPS that runs rendal.me. By the end, you'll have a provisioned Hetzner server with SSH locked down through Tailscale, fail2ban running as a safety net, and a clear mental model of why each layer exists.
Why Hetzner
I wanted a simple VPS for a personal Rails site — nothing managed, nothing abstracted away, just a server I control. Hetzner is the obvious choice if you're in Europe or fine with European data centers: the price-to-hardware ratio is genuinely hard to beat. A CX22 (2 vCPUs, 4 GB RAM, 40 GB NVMe) costs around €4/month. The equivalent on DigitalOcean or AWS is two to three times that.
The Cloud Console is clean and fast, the network is reliable, and they have a straightforward firewall product at the network level. No complaints after running it for months.
Provisioning the Server
Log into the Hetzner Cloud Console and create a new project. Projects are just organizational containers — you can put all your servers, firewalls, and SSH keys for a given thing in one place.
Inside the project, click Add Server. The choices that matter:
Location: Pick whatever's geographically closest to your users. For a personal site with no strong preference, Helsinki or Falkenstein are both fine.
Image: Ubuntu 24.04. Stable, well-documented, apt just works, and most tutorials you'll find for server tooling assume Debian/Ubuntu.
Type: For a personal Rails app, the CX22 is plenty. You can always resize later if you need to.
SSH keys: This is important to get right upfront. Add your public key here before the server is created. Hetzner will inject it into the authorized_keys for the root user, so your first login is already key-authenticated. If you don't have an Ed25519 key pair yet:
ssh-keygen -t ed25519 -C "[email protected]"
Paste the contents of ~/.ssh/id_ed25519.pub into the Hetzner SSH key field.
Networking: Leave the public IPv4 enabled for now. You'll tighten firewall rules shortly.
Create the server. Hetzner provisions it in about 30 seconds.
The Hetzner Cloud Firewall
Before you SSH in and touch anything, set up a firewall at the network level. This is separate from any firewall running on the server itself — it operates in Hetzner's infrastructure and blocks traffic before it even reaches your instance.
Go to Firewalls in the left sidebar and create a new one. The default inbound rules allow everything. Replace them with:
Direction | Protocol | Port | Source |
|---|---|---|---|
Inbound | TCP | 22 | Any |
Inbound | TCP | 80 | Any |
Inbound | TCP | 443 | Any |
Port 22 stays open for now — you need SSH access to configure everything else. You'll close it later once Tailscale is running. Outbound rules can stay as "allow all."
Apply the firewall to your server under the Resources tab.
The principle here is default-deny at the network perimeter. Nothing reaches your server except the specific ports you've explicitly allowed. Every other port is just gone from the internet's perspective — not rejected, not filtered, simply unreachable.
First Login and Baseline Hardening
SSH in as root using the server's public IP:
ssh root@<your-server-ip>
First, update everything:
apt update && apt upgrade -y
Lock Down SSH
Edit /etc/ssh/sshd_config:
PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin prohibit-password
Then restart:
systemctl restart ssh
Key-only authentication means brute-force password attacks are pointless. Bots don't know that though — they'll keep hammering port 22 regardless. You'll see this if you check the auth log after even a few hours on a public IP:
journalctl -u ssh --since "24 hours ago" | grep "Failed password" | wc -l
The number is always higher than you expect. Which brings us to the next layer.
Create a Deploy User
Running everything as root works, but it's unnecessarily risky. Create a dedicated user for day-to-day operations and deployments:
useradd -m -s /bin/bash deploy
usermod -aG sudo deploy
mkdir -p /home/deploy/.ssh
cp ~/.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
echo "deploy ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/deploy
Open a new terminal and verify SSH works as the deploy user before continuing:
ssh deploy@<tailscale-ip>
Once the deploy user is confirmed working, you can tighten further by setting PermitRootLogin no in sshd_config to disable root SSH entirely. For most setups, prohibit-password is sufficient.
fail2ban
fail2ban watches your log files for repeated failed authentication attempts and temporarily bans offending IPs using firewall rules. It's not your primary defense — key-only SSH is — but it's a useful safety net for the cases where you temporarily slip (debugging with password auth enabled, a misconfigured sshd_config, etc.).
Install it:
apt install fail2ban -y
Create a local config (the default jail.conf gets overwritten on updates — always work in jail.local):
cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
The key settings in /etc/fail2ban/jail.local:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 3
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 3
Three failed attempts within 10 minutes gets an IP banned for an hour. You can be more aggressive — some people set bantime to 24 hours or use bantime = -1 for permanent bans. I find an hour is enough to make automated attacks non-viable without the operational overhead of managing permanent bans.
Start and enable it:
systemctl enable fail2ban
systemctl start fail2ban
Check what it's doing:
fail2ban-client status sshd
Within a few hours, you'll see IPs showing up in the banned list. It's grimly satisfying.
Tailscale
This is where the setup gets genuinely interesting. Tailscale creates a private WireGuard-based mesh network between your devices. Your server gets a stable private IP in the 100.x.y.z range that's only reachable from your other Tailscale-connected machines.
The goal: stop SSH-ing to the server's public IP entirely. Once Tailscale is running, you SSH to the private Tailscale IP, then close port 22 on the public firewall completely. The server stays reachable for web traffic on 80 and 443, but SSH is invisible to the internet. Bots can't brute-force a port that doesn't exist as far as they're concerned.
Install Tailscale on the Server
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
This gives you a URL to authenticate the server with your Tailscale account. Once authenticated, it joins your tailnet and gets a private IP.
Check it:
tailscale ip -4
Note that IP — you'll use it for everything going forward.
Install Tailscale on Your Machine
Install the Tailscale client on your laptop too. It's available for macOS, Linux, Windows, iOS, and Android. Once both devices are authenticated to the same tailnet, they can reach each other at their Tailscale IPs regardless of what network either is on.
Test SSH over Tailscale:
ssh [email protected]
If that works, you're ready to close the public port.
Close Port 22 on the Hetzner Firewall
Go back to the Hetzner Cloud Console and remove the inbound rule for port 22.
That's it. SSH is now only accessible through the Tailscale network. The server's public IP still answers on 80 and 443, but port 22 doesn't exist from the internet's perspective.
SSH Config for Convenience
Add the server to your local SSH config so you don't have to remember the Tailscale IP:
# ~/.ssh/config
Host rendal
HostName 100.x.y.z
User deploy
IdentityFile ~/.ssh/id_ed25519
Now ssh rendal just works, routing through Tailscale automatically.
Kamal and Tailscale
Kamal deploys over SSH, so it also needs to connect through Tailscale now that port 22 is closed publicly. Update config/deploy.yml to use the Tailscale IP:
servers:
web:
- 100.x.y.z
This means you can only deploy from a machine that's on your tailnet, which for a personal project is a non-issue — you're always deploying from your own laptop. If you needed CI/CD deployments, you'd run Tailscale on the CI runner too, or scope a separate firewall rule to the CI provider's IP range.
UFW: Belt and Suspenders
The Hetzner firewall operates at the network level, outside your server. I also set up UFW on the server itself as a second layer — defense in depth, not redundancy.
ufw default deny incoming
ufw default allow outgoing
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow in on tailscale0 to any port 22
ufw enable
The key line is ufw allow in on tailscale0 to any port 22. This allows SSH only on the Tailscale network interface. Even if someone bypassed the Hetzner network firewall somehow, the server itself would reject SSH connections arriving on the public interface.
Check the rules are applied correctly:
ufw status verbose
What You Now Have
After all of this:
- SSH is invisible to the public internet. Port 22 is closed at both the network level (Hetzner firewall) and the host level (UFW). The only way in is through Tailscale.
- fail2ban is a safety net for the rare case you need to temporarily expose SSH publicly.
- Web traffic flows normally on ports 80 and 443.
- Deployments run over the Tailscale network from your laptop.
The bot traffic didn't stop — it just can't reach anything anymore. In practice, the auth log went from hundreds of daily failed attempts to nothing.
Each of these layers took about 10 minutes to set up. None of them are exotic. The value is in understanding how they compose: network-level firewall for the perimeter, key-only SSH to make password attacks irrelevant, fail2ban as a behavioral safety net, Tailscale to eliminate the public attack surface entirely, UFW as a host-level backstop. Remove any one layer and the others cover it. That's the point.