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
# Install Tailscalecurl -fsSL https://tailscale.com/install.sh | sh
# Authenticate with your Tailscale accountsudo tailscale up
# Check your Tailscale IPtailscale 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:
sudo ufw allow sshsudo ufw allow 3000sudo ufw enableThis left port 3000 open to the world. Not good.
Second attempt - Better but still gaps:
sudo ufw default deny incomingsudo ufw default allow outgoingsudo ufw allow sshsudo ufw enableBetter, but SSH on the default port attracts brute force attacks.
Final configuration - Locked down:
# Reset to defaultssudo ufw default deny incomingsudo 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 trafficsudo ufw allow in on tailscale0
# Allow bot dashboard ONLY from Tailscale networksudo ufw allow from 100.0.0.0/10 to any port 3000 comment 'Bot API - Tailscale only'
# Enable firewallsudo ufw enable
# Verifysudo ufw status verboseThe 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
# Install certbotsudo apt install certbot python3-certbot-nginx
# Get certificatesudo 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.pemNginx Reverse Proxy Configuration
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
[Unit]Description=Claude AI Bot GatewayAfter=network.targetWants=network-online.target
[Service]Type=simpleUser=botuserGroup=botuserWorkingDirectory=/home/botuser/claud-botExecStart=/home/botuser/.nvm/versions/node/v20.10.0/bin/node src/index.jsRestart=alwaysRestartSec=10TimeoutStopSec=30
# EnvironmentEnvironment="NODE_ENV=production"EnvironmentFile=/home/botuser/claud-bot/.env
# Security hardeningNoNewPrivileges=truePrivateTmp=trueProtectSystem=strictProtectHome=trueReadWritePaths=/home/botuser/claud-bot/logs
# Resource limitsLimitNOFILE=65536MemoryMax=2G
[Install]WantedBy=multi-user.targetKey settings I learned about:
Restart=always- Restarts on crash, not just “clean” exitsRestartSec=10- Waits 10 seconds before restart (avoids thrashing)NoNewPrivileges=true- Prevents privilege escalationMemoryMax=2G- Kills the process if it uses too much memory
# Enable and startsudo systemctl daemon-reloadsudo systemctl enable claud-botsudo systemctl start claud-bot
# Check statussudo systemctl status claud-bot
# View logsjournalctl -u claud-bot -fSecurity Layer 5: User Isolation
Running the bot as root is asking for trouble. I created a dedicated, unprivileged user.
# Create user with limited accesssudo useradd -r -s /bin/bash -d /home/botuser botuser
# Create application directorysudo mkdir -p /home/botuser/claud-botsudo 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 breakagePinning 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.
# Edit SSH configsudo nano /etc/ssh/sshd_config
# Key changes:Port 2222 # Non-standard portPermitRootLogin no # Disable root loginPasswordAuthentication no # Require SSH keysPubkeyAuthentication yes
# Restart SSHsudo systemctl restart sshdNow 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:
# Keep an active SSH session open before enabling UFW# Then test from a new terminal before closing the old onePitfall 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.serviceWants=network-online.target tailscale.servicePitfall 3: Certificate Renewal Failure
Let’s Encrypt certificates expire after 90 days. I set up auto-renewal:
# Test renewalsudo certbot renew --dry-run
# Certbot adds a systemd timer automaticallysudo systemctl status certbot.timerArchitecture 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-botfor 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:
- Network access control - Tailscale creates a private network overlay
- Firewall rules - UFW blocks all traffic except what’s explicitly allowed
- TLS encryption - Let’s Encrypt certificates for HTTPS
- Service reliability - systemd auto-restart keeps your bot running
- 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:
- 👨💻 Tailscale Documentation
- 👨💻 Let's Encrypt Documentation
- 👨💻 systemd Service Documentation
- 👨💻 UFW - Uncomplicated Firewall
- 👨💻 WireGuard VPN
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments