Skip to content

How to Secure a VPS for AI Bots: Tailscale, Firewall & TLS

I just deployed a Claude bot on a fresh VPS and immediately realized I’d left the dashboard exposed to the entire internet. That sinking feeling when you see your admin panel accessible from anywhere is not fun. Here’s how I locked it down properly.

The Problem: Exposed Services

When you spin up a VPS and deploy a bot with a web dashboard, it’s accessible on http://your-vps-ip:3000. Anyone can:

  • Scan your ports with nmap
  • Find your dashboard
  • Try to access or attack it

This is especially risky for AI bots that might have API keys, user data, or administrative controls exposed.

Security Layer 1: Network Access Control

The most critical question: who can access your VPS?

Why I Chose Tailscale Over Alternatives

I considered three options:

+------------------+-------------------+------------------+
| | Pros | Cons |
+------------------+-------------------+------------------+
| Tailscale | Zero config, | Requires account |
| | works behind NAT, | and internet |
| | free for personal | connection |
| | use | |
+------------------+-------------------+------------------+
| WireGuard | Fast, low | Manual key |
| | overhead, self- | management, |
| | hosted | port forwarding |
+------------------+-------------------+------------------+
| SSH Tunnels | No extra tools | Single port, |
| | needed | no GUI access |
| | | without tricks |
+------------------+-------------------+------------------+

Tailscale won because I wanted something that “just works” without configuring port forwarding or managing keys.

Installing Tailscale

Terminal window
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
# Authenticate with your Tailscale account
sudo tailscale up
# Check your Tailscale IP
tailscale ip
# Output: 100.x.y.z (this is your private network IP)

Now I can access my VPS using 100.x.y.z instead of the public IP, and only devices I authorize can connect.

Security Layer 2: Firewall Rules

With Tailscale handling access control, I locked down the VPS itself using UFW (Uncomplicated Firewall).

My Trial-and-Error Process

First attempt - Too permissive:

Terminal window
sudo ufw allow ssh
sudo ufw allow 3000
sudo ufw enable

This left port 3000 open to the world. Not good.

Second attempt - Better but still gaps:

Terminal window
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw enable

Better, but SSH on the default port attracts brute force attacks.

Final configuration - Locked down:

Terminal window
# Reset to defaults
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH on non-standard port (I changed SSH to 2222)
sudo ufw allow 2222/tcp comment 'SSH'
# Allow all Tailscale traffic
sudo ufw allow in on tailscale0
# Allow bot dashboard ONLY from Tailscale network
sudo ufw allow from 100.0.0.0/10 to any port 3000 comment 'Bot API - Tailscale only'
# Enable firewall
sudo ufw enable
# Verify
sudo ufw status verbose

The key insight: 100.0.0.0/10 is the Tailscale IP range. By restricting port 3000 to this range, my dashboard is only accessible through the Tailscale network.

Security Layer 3: TLS Certificates

Even with restricted access, I wanted HTTPS for the dashboard. Running sensitive operations over HTTP, even on a private network, feels wrong.

Using Let’s Encrypt with Certbot

Terminal window
# Install certbot
sudo apt install certbot python3-certbot-nginx
# Get certificate
sudo certbot certonly --nginx -d bot.yourdomain.com
# Certificates stored at:
# /etc/letsencrypt/live/bot.yourdomain.com/fullchain.pem
# /etc/letsencrypt/live/bot.yourdomain.com/privkey.pem

Nginx Reverse Proxy Configuration

/etc/nginx/sites-available/bot-dashboard
server {
listen 80;
server_name bot.yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name bot.yourdomain.com;
# TLS certificates
ssl_certificate /etc/letsencrypt/live/bot.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bot.yourdomain.com/privkey.pem;
# Modern TLS settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_tickets off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# Restrict to Tailscale network only
allow 100.0.0.0/10;
deny all;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

The allow 100.0.0.0/10; deny all; block ensures Nginx rejects any request not from Tailscale, adding another layer of defense.

Security Layer 4: Service Reliability

A crashed bot service is a problem. If I can’t monitor it, I won’t know it’s down until users complain.

Systemd Service with Auto-Restart

/etc/systemd/system/claud-bot.service
[Unit]
Description=Claude AI Bot Gateway
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=botuser
Group=botuser
WorkingDirectory=/home/botuser/claud-bot
ExecStart=/home/botuser/.nvm/versions/node/v20.10.0/bin/node src/index.js
Restart=always
RestartSec=10
TimeoutStopSec=30
# Environment
Environment="NODE_ENV=production"
EnvironmentFile=/home/botuser/claud-bot/.env
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/home/botuser/claud-bot/logs
# Resource limits
LimitNOFILE=65536
MemoryMax=2G
[Install]
WantedBy=multi-user.target

Key settings I learned about:

  • Restart=always - Restarts on crash, not just “clean” exits
  • RestartSec=10 - Waits 10 seconds before restart (avoids thrashing)
  • NoNewPrivileges=true - Prevents privilege escalation
  • MemoryMax=2G - Kills the process if it uses too much memory
Terminal window
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable claud-bot
sudo systemctl start claud-bot
# Check status
sudo systemctl status claud-bot
# View logs
journalctl -u claud-bot -f

Security Layer 5: User Isolation

Running the bot as root is asking for trouble. I created a dedicated, unprivileged user.

Terminal window
# Create user with limited access
sudo useradd -r -s /bin/bash -d /home/botuser botuser
# Create application directory
sudo mkdir -p /home/botuser/claud-bot
sudo chown -R botuser:botuser /home/botuser
# Set up Node.js with nvm (as botuser, not root)
sudo -u botuser bash -c 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash'
sudo -u botuser bash -c 'source ~/.nvm/nvm.sh && nvm install 20.10.0 && nvm use 20.10.0 && nvm alias default 20.10.0'
# Pin Node.js version to prevent random breakage

Pinning Node.js version with nvm prevents system updates from breaking the bot unexpectedly.

SSH Hardening

While Tailscale handles most access, I still hardened SSH for the occasional need to connect without Tailscale.

Terminal window
# Edit SSH config
sudo nano /etc/ssh/sshd_config
# Key changes:
Port 2222 # Non-standard port
PermitRootLogin no # Disable root login
PasswordAuthentication no # Require SSH keys
PubkeyAuthentication yes
AllowUsers [email protected]/10 # Restrict to Tailscale network
# Restart SSH
sudo systemctl restart sshd

Now SSH only accepts key-based auth from the Tailscale network on a non-standard port.

Common Pitfalls I Encountered

Pitfall 1: Locking Myself Out

I almost locked myself out when I enabled UFW before changing the SSH port. Always test your firewall rules with a second terminal open:

Terminal window
# Keep an active SSH session open before enabling UFW
# Then test from a new terminal before closing the old one

Pitfall 2: Tailscale Not Starting on Boot

The bot service tried to start before Tailscale connected, causing network failures. Fixed by adding proper dependencies:

[Unit]
After=network.target tailscale.service
Wants=network-online.target tailscale.service

Pitfall 3: Certificate Renewal Failure

Let’s Encrypt certificates expire after 90 days. I set up auto-renewal:

Terminal window
# Test renewal
sudo certbot renew --dry-run
# Certbot adds a systemd timer automatically
sudo systemctl status certbot.timer

Architecture Overview

Here’s the final setup:

Internet
|
v
+---------------+
| Firewall | (UFW: deny all by default)
+---------------+
|
Port 2222 only | Tailscale traffic allowed
|
v
+---------------+
| Tailscale | (Private VPN network)
| 100.x.y.z |
+---------------+
|
v
+---------------+
| Nginx | (TLS termination, Tailscale-only)
+---------------+
|
v
+---------------+
| Bot Service | (systemd, isolated user)
| port 3000 |
+---------------+

Maintenance Schedule

Security isn’t a one-time setup. I added these to my calendar:

  • Weekly: Check journalctl -u claud-bot for errors
  • Monthly: Review UFW logs for suspicious activity
  • Quarterly: Update system packages and Node.js version
  • Annually: Rotate API keys and review access permissions

What This Doesn’t Cover

This setup assumes:

  • You have a domain name pointing to your VPS
  • DNS is configured (Cloudflare or similar)
  • You’re comfortable with command line administration

For monitoring, I’m using a simple health check script that pings the dashboard every minute and sends me an alert if it’s down. For production use, consider Prometheus + Grafana or a managed monitoring service.

Summary

Securing a VPS for AI bots requires five layers:

  1. Network access control - Tailscale creates a private network overlay
  2. Firewall rules - UFW blocks all traffic except what’s explicitly allowed
  3. TLS encryption - Let’s Encrypt certificates for HTTPS
  4. Service reliability - systemd auto-restart keeps your bot running
  5. User isolation - Dedicated unprivileged user limits damage

The result: your bot dashboard is only accessible from devices you control, over encrypted connections, with automatic recovery from crashes. No public exposure, no open ports to scan, no easy attack surface.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments