← Blog

Hardening an OpenClaw VPS - Zero Trust Setup with Tailscale

6 min read

A step-by-step guide to securing a fresh Ubuntu VPS for hosting personal projects. The goal is a layered defence: Tailscale as the private network, UFW as the firewall, SSH hardening, and fail2ban for intrusion prevention.

This is also the security baseline I use to run OpenClaw safely on a public VPS without exposing control interfaces to the internet.

Why Layered Security?

No single measure is bulletproof:

  • Tailscale - only your devices can reach SSH
  • UFW - blocks all uninvited public traffic
  • SSH hardening - keys only, no passwords
  • fail2ban - bans IPs after repeated failures
  • Auto-updates - patches vulnerabilities automatically

Each layer independently reduces risk. If one fails, the others hold.

Tailscale - Private Mesh Network

Install and authenticate Tailscale first. Everything else builds on top of it.

curl -fsSL https://tailscale.com/install.sh | sh
tailscale up

Open the auth URL printed in the terminal, approve the device in your Tailscale dashboard, then verify:

tailscale status

You should see your VPS listed with a 100.x.x.x IP. From now on, use that IP to connect - not the public one.

Enable auto-updates:

tailscale set --auto-update

UFW - Firewall

Block all public inbound traffic. Allow only Tailscale, HTTP, and HTTPS.

# SSH only through Tailscale
ufw allow in on tailscale0 to any port 22 proto tcp

# Public web traffic (for hosting a blog/app)
ufw allow 80/tcp
ufw allow 443/tcp

# Allow all Tailscale traffic
ufw allow in on tailscale0

# Default policies
ufw default deny incoming
ufw default allow outgoing

echo 'y' | ufw enable
ufw status verbose

Note: After enabling UFW, ssh user@public-ip will time out. Always connect via the Tailscale IP going forward.

SSH Hardening

Create the hardening config file:

cat > /etc/ssh/sshd_config.d/hardening.conf << 'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
MaxAuthTries 3
LoginGraceTime 20
X11Forwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
ListenAddress YOUR_TAILSCALE_IP
ListenAddress 127.0.0.1
EOF

Replace YOUR_TAILSCALE_IP with your actual Tailscale address (e.g. 100.x.x.x).

Test and apply:

sshd -t && systemctl restart ssh

Ubuntu 24.04 gotcha: Ubuntu 24.04 uses systemd socket activation for SSH. This means ListenAddress in sshd_config is ignored — the socket unit controls which interface SSH binds to. You must override it separately:

mkdir -p /etc/systemd/system/ssh.socket.d
cat > /etc/systemd/system/ssh.socket.d/override.conf << 'EOF'
[Socket]
ListenStream=
ListenStream=YOUR_TAILSCALE_IP:22
ListenStream=[::1]:22
EOF

systemctl daemon-reload
systemctl restart ssh.socket ssh.service

The empty ListenStream= clears the default 0.0.0.0:22 binding before setting the new one. Verify it worked:

ss -tlnp | grep sshd
# Should show YOUR_TAILSCALE_IP:22 only, not 0.0.0.0:22

What each directive does:

DirectiveEffect
PasswordAuthentication noKeys only - no brute-force via password
KbdInteractiveAuthentication noBlocks interactive keyboard auth
PermitRootLogin prohibit-passwordRoot allowed via key, never via password
ListenAddressSSH only reachable on Tailscale interface
MaxAuthTries 3Limits guesses per connection
X11Forwarding noRemoves unnecessary attack surface

Non-root User

Never run your daily work as root. Create a dedicated user:

useradd -m -s /bin/bash youruser
usermod -aG sudo youruser

# Copy your SSH key
mkdir -p /home/youruser/.ssh
cp /root/.ssh/authorized_keys /home/youruser/.ssh/authorized_keys
chown -R youruser:youruser /home/youruser/.ssh
chmod 700 /home/youruser/.ssh
chmod 600 /home/youruser/.ssh/authorized_keys

Optional - passwordless sudo (safe when password auth is disabled):

echo 'youruser ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/youruser
chmod 440 /etc/sudoers.d/youruser

Verify before locking root out:

ssh youruser@YOUR_TAILSCALE_IP "whoami && sudo whoami"

Fail2ban - Intrusion Prevention

apt-get install -y fail2ban

Configure aggressive SSH protection with a recidive jail for repeat offenders:

cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime  = 24h
findtime = 10m
maxretry = 3
backend  = auto
ignoreip = 127.0.0.1/8 100.64.0.0/10

[sshd]
enabled = true
mode    = aggressive

[recidive]
enabled   = true
logpath   = /var/log/fail2ban.log
banaction = nftables[type=allports]
bantime   = 168h
findtime  = 24h
maxretry  = 3
EOF

systemctl enable fail2ban && systemctl restart fail2ban

The ignoreip line ensures Tailscale addresses (100.64.0.0/10) are never accidentally banned.

Ubuntu 24.04 gotcha: Ubuntu ships /etc/fail2ban/jail.d/defaults-debian.conf which sets backend = systemd. This means fail2ban reads only from the systemd journal, not from /var/log/auth.log. Setting backend = auto in jail.local overrides this and makes fail2ban read log files directly — which is more reliable and catches attacks even if the journal is rotated or truncated.

The recidive jail watches fail2ban's own log. Any IP that gets banned 3 or more times within 24 hours is blocked on all ports for 7 days — catching scanners that rotate through different exploits.

Check status:

fail2ban-client status sshd
fail2ban-client status recidive

Automatic Security Updates

Keep the OS patched without manual intervention:

apt-get install -y unattended-upgrades
systemctl enable unattended-upgrades

Dedicated Service Users

For each app you host (e.g. OpenClaw, a blog engine), create an isolated user with no sudo and no SSH access:

useradd -m -s /bin/bash -c 'App description' appname

Running services as dedicated users limits blast radius - a compromised app can't touch the rest of the system.

Docker + UFW Warning

If you plan to run Docker containers, be aware that Docker bypasses UFW by writing iptables rules directly. A container published with -p 80:80 will be publicly accessible even if UFW has no rule for it.

The fix: always put Nginx as a reverse proxy in front of your containers, bind Docker to localhost only, and let Nginx handle the public-facing ports that UFW allows.

# Bind container to localhost only — Nginx proxies it publicly
docker run -p 127.0.0.1:3000:3000 myapp

Emergency Access

If Tailscale ever goes down and you can't SSH in, use your provider's rescue console (Hetzner, DigitalOcean, etc.) - it gives direct KVM access to the server regardless of network state. Bookmark it.

OpenClaw-Specific Hardening

When the VPS is used for OpenClaw, add these controls on top of host hardening:

  • Bind OpenClaw Gateway to loopback (127.0.0.1) so it is never directly internet-exposed.
  • Access the OpenClaw UI only through trusted private networking (Tailscale) or a secured relay.
  • Require explicit device pairing approval for any new browser/device trying to control the agent.
  • Run OpenClaw under a dedicated service user with least privilege.
  • Keep credentials in environment or secret storage, never in repo files or shell history.

This keeps the control plane private while still allowing convenient remote access from your own devices.

Final Checklist

  • Tailscale installed and authenticated
  • UFW active - SSH Tailscale-only, HTTP/HTTPS public
  • SSH: keys only, no passwords, ListenAddress set
  • Non-root user created and verified
  • Root login: key only (prohibit-password)
  • fail2ban active - aggressive mode, recidive jail enabled
  • unattended-upgrades active
  • Tailscale auto-update enabled
  • Rescue console bookmarked