Skip to content
Back to blog Running Clawdbot 24/7 on a Hetzner VPS – Terraform, Security Hardening, and the Bits the Docs Miss

Running Clawdbot 24/7 on a Hetzner VPS – Terraform, Security Hardening, and the Bits the Docs Miss

TerraformSecurity

Clawdbot has been everywhere in January 2026. The development velocity is mental – new features landing daily, skills ecosystem expanding, and the community building integrations faster than I can keep up.

The official docs are solid, but they assume you’re clicking through a web console and SSHing in with a password. That’s not how we do things.

This is a production-grade walkthrough: Terraform-provisioned Hetzner VPS, proper security hardening, and the gotchas I hit getting Clawdbot running 24/7.

Infrastructure as Code - Hetzner VPS with Terraform

No clicking around in consoles. Here’s the Terraform to spin up a VPS with SSH keys, firewall rules, and optional Tailscale bootstrap.

Repo Structure

.
├── main.tf
├── variables.tf
├── outputs.tf
├── data.tf
├── terraform.tfvars.example
└── scripts/
    └── cloud-init.sh

main.tf

# SSH Key
resource "hcloud_ssh_key" "default" {
  name       = "${var.server_name}-ssh-key"
  public_key = var.ssh_public_key
}

# Cloud-init script
locals {
  user_data = templatefile("${path.module}/scripts/cloud-init.sh", {
    tailscale_auth_key = var.tailscale_auth_key
    username           = var.username
    ssh_public_key     = var.ssh_public_key
  })
}

# Server
resource "hcloud_server" "vps" {
  name        = var.server_name
  image       = var.image
  server_type = data.hcloud_server_type.selected.name
  location    = data.hcloud_location.selected.name
  ssh_keys    = concat([hcloud_ssh_key.default.id], var.ssh_keys)
  user_data   = local.user_data

  labels = {
    managed-by  = "terraform"
    environment = var.environment
    purpose     = "clawdbot"
  }
}

# Firewall – locked down by default
resource "hcloud_firewall" "vps" {
  name = "${var.server_name}-firewall"

  # SSH: Tailscale CGNAT range + explicit allowed IPs
  rule {
    direction   = "in"
    protocol    = "tcp"
    port        = "22"
    source_ips  = var.tailscale_auth_key != "" ? concat(["100.64.0.0/10"], var.allowed_ssh_ips) : var.allowed_ssh_ips
    description = "SSH access"
  }

  # ICMP for diagnostics
  rule {
    direction   = "in"
    protocol    = "icmp"
    source_ips  = ["0.0.0.0/0", "::/0"]
    description = "ICMP (ping)"
  }

  # Egress – allow all (Hetzner default, but explicit is better)
  rule {
    direction       = "out"
    protocol        = "tcp"
    port            = "1-65535"
    destination_ips = ["0.0.0.0/0", "::/0"]
    description     = "All TCP outbound"
  }

  rule {
    direction       = "out"
    protocol        = "udp"
    port            = "1-65535"
    destination_ips = ["0.0.0.0/0", "::/0"]
    description     = "All UDP outbound"
  }

  rule {
    direction       = "out"
    protocol        = "icmp"
    destination_ips = ["0.0.0.0/0", "::/0"]
    description     = "ICMP outbound"
  }
}

resource "hcloud_firewall_attachment" "vps" {
  firewall_id = hcloud_firewall.vps.id
  server_ids  = [hcloud_server.vps.id]
}

variables.tf

variable "hcloud_token" {
  description = "Hetzner Cloud API token"
  type        = string
  sensitive   = true
}

variable "server_name" {
  description = "Server hostname"
  type        = string
  default     = "clawdbot"
}

variable "server_type" {
  description = "Hetzner server type (cx22 = 2 vCPU, 4GB RAM)"
  type        = string
  default     = "cx22"
}

variable "image" {
  description = "OS image"
  type        = string
  default     = "ubuntu-24.04"
}

variable "location" {
  description = "Hetzner datacenter"
  type        = string
  default     = "nbg1"  # Nuremberg, DE
}

variable "ssh_public_key" {
  description = "SSH public key for access"
  type        = string
}

variable "ssh_keys" {
  description = "Additional SSH key IDs"
  type        = list(string)
  default     = []
}

variable "username" {
  description = "Non-root user to create"
  type        = string
  default     = "clawdbot"
}

variable "tailscale_auth_key" {
  description = "Tailscale auth key (optional)"
  type        = string
  default     = ""
  sensitive   = true
}

variable "allowed_ssh_ips" {
  description = "IPs allowed to SSH (use your static IP or VPN range)"
  type        = list(string)
  default     = []  # Empty = SSH only via Tailscale if enabled
}

variable "environment" {
  description = "Environment label"
  type        = string
  default     = "production"
}

data.tf

terraform {
  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.45"
    }
  }
}

provider "hcloud" {
  token = var.hcloud_token
}

data "hcloud_server_type" "selected" {
  name = var.server_type
}

data "hcloud_location" "selected" {
  name = var.location
}

outputs.tf

output "server_ip" {
  description = "Public IPv4 address"
  value       = hcloud_server.vps.ipv4_address
}

output "server_ipv6" {
  description = "Public IPv6 address"
  value       = hcloud_server.vps.ipv6_address
}

output "ssh_command" {
  description = "SSH connection string"
  value       = "ssh ${var.username}@${hcloud_server.vps.ipv4_address}"
}

scripts/cloud-init.sh

This is where the security hardening happens. Cloud-init runs on first boot – no manual SSH required.

#!/bin/bash
set -euo pipefail

# Variables from Terraform
TAILSCALE_AUTH_KEY="${tailscale_auth_key}"
USERNAME="${username}"
SSH_PUBLIC_KEY="${ssh_public_key}"

# Logging
exec > >(tee /var/log/cloud-init-custom.log) 2>&1
echo "=== Cloud-init started at $(date) ==="

# System updates
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y

# Install essentials
DEBIAN_FRONTEND=noninteractive apt-get install -y \
  curl \
  git \
  vim \
  htop \
  fail2ban \
  ufw \
  unattended-upgrades \
  apt-listchanges

# Create non-root user
if ! id "$USERNAME" &>/dev/null; then
  useradd -m -s /bin/bash -G sudo "$USERNAME"
  echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME
  chmod 0440 /etc/sudoers.d/$USERNAME
fi

# SSH key for user
USER_HOME="/home/$USERNAME"
mkdir -p "$USER_HOME/.ssh"
echo "$SSH_PUBLIC_KEY" > "$USER_HOME/.ssh/authorized_keys"
chmod 700 "$USER_HOME/.ssh"
chmod 600 "$USER_HOME/.ssh/authorized_keys"
chown -R "$USERNAME:$USERNAME" "$USER_HOME/.ssh"

# SSH hardening
cat > /etc/ssh/sshd_config.d/hardening.conf << 'EOF'
# Disable password authentication
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes

# Disable root login
PermitRootLogin no

# Key-based auth only
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys

# Timeouts and limits
ClientAliveInterval 300
ClientAliveCountMax 2
MaxAuthTries 3
MaxSessions 3
LoginGraceTime 30

# Disable unused auth methods
HostbasedAuthentication no
PermitEmptyPasswords no
KerberosAuthentication no
GSSAPIAuthentication no

# Logging
LogLevel VERBOSE
EOF

# Restart SSH
systemctl restart ssh

# fail2ban configuration
cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
banaction = ufw

[sshd]
enabled = true
port = ssh
logpath = /var/log/auth.log
maxretry = 3
bantime = 24h
EOF

systemctl enable fail2ban
systemctl restart fail2ban

# UFW firewall
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw --force enable

# Unattended upgrades – security patches only
cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF'
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF

cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF

systemctl enable unattended-upgrades

# Kernel hardening via sysctl
cat > /etc/sysctl.d/99-security.conf << 'EOF'
# IP Spoofing protection
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# Ignore ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0

# Ignore source routed packets
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0

# Log Martian packets
net.ipv4.conf.all.log_martians = 1

# Ignore broadcast pings
net.ipv4.icmp_echo_ignore_broadcasts = 1

# Disable IPv6 if not needed (optional)
# net.ipv6.conf.all.disable_ipv6 = 1
EOF

sysctl -p /etc/sysctl.d/99-security.conf

# Tailscale (optional)
if [ -n "$TAILSCALE_AUTH_KEY" ]; then
  curl -fsSL https://tailscale.com/install.sh | sh
  tailscale up --authkey="$TAILSCALE_AUTH_KEY" --ssh
  echo "Tailscale installed and connected"
fi

echo "=== Cloud-init completed at $(date) ==="

terraform.tfvars.example

hcloud_token   = "your-hetzner-api-token"
server_name    = "clawdbot-prod"
server_type    = "cx22"
image          = "ubuntu-24.04"
location       = "nbg1"
ssh_public_key = "ssh-ed25519 AAAA... you@machine"

# Security: restrict SSH to your IP or VPN
allowed_ssh_ips = ["YOUR_IP/32"]

# Optional: Tailscale for zero-trust access
# tailscale_auth_key = "tskey-auth-xxxxx"

Deployment

cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your values

terraform init
terraform plan
terraform apply

Wait ~2 minutes for cloud-init to complete. Check progress:

ssh clawdbot@$(terraform output -raw server_ip) 'tail -f /var/log/cloud-init-custom.log'

Tailscale Setup – Getting Your Auth Key

If you want zero-trust access to your Clawdbot gateway (and you should), you’ll need a Tailscale auth key before running Terraform.

Create a Tailscale Account

  1. Head to tailscale.com and sign up (free tier is plenty)
  2. Install Tailscale on your local machine – this is how you’ll access the VPS securely

Generate an Auth Key

  1. Go to Tailscale Admin Console

  2. Click Generate auth key

  3. Settings I use:

    • Reusable: No (one-time use is more secure)
    • Ephemeral: No (we want the node to persist)
    • Pre-approved: Yes (skips manual approval)
    • Tags: Optional, but useful if you have ACLs (tag:servers)
    • Expiry: 1 hour is fine – it’s only used during cloud-init
  4. Copy the key – it looks like tskey-auth-kXYZ123CNTRL-abc123...

This key goes into your terraform.tfvars:

tailscale_auth_key = "tskey-auth-kXYZ123CNTRL-abc123..."

Why Tailscale?

The VPS binds Clawdbot to 127.0.0.1 – it’s not exposed to the public internet. Tailscale creates a private mesh network between your devices. You access the dashboard via https://clawdbot.tail1234.ts.net (private HTTPS, no port forwarding, no firewall holes).

If you skip Tailscale, you’ll need to either:

  • SSH tunnel every time (ssh -L 18789:localhost:18789 clawdbot@server)
  • Expose the gateway to 0.0.0.0 with token auth (less secure)

Networking and Security Hardening

The cloud-init script handles the heavy lifting, but here’s what’s actually happening:

Defence in Depth

Internet → Hetzner Firewall → UFW → Application
              (hypervisor)    (kernel)   (userspace)

Hetzner Firewall – Filters at the hypervisor level. Traffic is dropped before it reaches your VM. Can’t be disabled from inside the VM (good for preventing compromise escalation).

UFW – Linux kernel firewall (iptables frontend). Second layer of filtering. Useful for per-application rules and logging.

fail2ban – Monitors /var/log/auth.log, bans IPs after 3 failed SSH attempts for 24 hours. Integrates with UFW for automatic blocking.

Kernel Hardening

The sysctl settings prevent common network attacks:

SettingWhat it does
rp_filter = 1Drops packets with spoofed source IPs
accept_redirects = 0Ignores ICMP redirects (prevents MitM)
accept_source_route = 0Blocks source-routed packets
log_martians = 1Logs packets with impossible addresses
icmp_echo_ignore_broadcasts = 1Prevents Smurf attacks

SSH Hardening

The custom sshd_config drops 90% of automated attacks:

  • No passwords – Key-only auth eliminates brute force
  • No root login – Attackers must guess username + key
  • 3 max auth tries – Slows down attacks
  • 30s login grace – Closes hanging connections fast
  • Verbose logging – Forensics if something goes wrong

Automatic Security Updates

unattended-upgrades applies security patches daily without intervention. Only security repos are enabled – no surprise feature changes breaking your setup.

Check what’s pending:

sudo unattended-upgrades --dry-run -v

Installing Clawdbot

SSH in as the clawdbot user:

ssh clawdbot@$(terraform output -raw server_ip)

Node.js via nvm

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.bashrc
nvm install 24
node -v  # v24.x

Homebrew (required for some skills)

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' >> ~/.bashrc
source ~/.bashrc

Clawdbot Installation

npm i -g clawdbot

This takes a minute or two. Once done, you’re ready to onboard.

Onboarding Walkthrough

Run clawdbot onboard and follow the interactive wizard. Clawdbot is moving fast, so options might change – but here’s what worked for me with annotations:

◆  I understand this is powerful and inherently risky. Continue?
│  Yes

◆  Onboarding mode
│  ○ QuickStart
│  ● Manual (Configure port, network, Tailscale, and auth options.)
│  # Use manual mode for more control

◆  What do you want to set up?
│  ● Local gateway (this machine) (Gateway reachable (ws://127.0.0.1:18789))
│  ○ Remote gateway (info-only)
│  # Counterintuitive, but "local" means the gateway runs on this VPS

◆  Workspace directory
│  /home/clawdbot/clawd

◆  Model/auth provider
│  ● OpenAI (Codex OAuth + API key)
│  ○ Anthropic
│  ○ MiniMax
│  ○ Qwen
│  ○ Synthetic
│  ○ Google
│  ○ Copilot
│  ...
│  # I use Anthropic with my Claude Pro subscription. Pick your provider.

◆  Gateway port
│  18789

◆  Gateway bind
│  ● Loopback (127.0.0.1)
│  ○ LAN (0.0.0.0)
│  ○ Tailnet (Tailscale IP)
│  ○ Auto (Loopback → LAN)
│  ○ Custom IP
│  # Loopback – only accessible via Tailscale or SSH tunnel

◆  Gateway auth
│  ○ Off (loopback only)
│  ● Token (Recommended default (local + remote))
│  ○ Password
│  # Token auth – you'll get a token for dashboard access

◆  Tailscale exposure
│  ○ Off
│  ● Serve (Private HTTPS for your tailnet (devices on Tailscale))
│  ○ Funnel
│  # Serve = private HTTPS within your tailnet. Funnel = public internet (avoid)

◆  Reset Tailscale serve/funnel on exit?
│  ○ Yes / ● No
│  # No – keeps the endpoint alive when gateway restarts

◆  Configure chat channels now?
│  ● Yes / ○ No
│  # Yes – this is how you'll interact with Clawdbot day-to-day

◇  Skills status ────────────╮
│                            │
│  Eligible: 13              │
│  Missing requirements: 38  │
│  Blocked by allowlist: 0   │
│                            │
├────────────────────────────╯

◆  Configure skills now? (recommended)
│  ● Yes / ○ No
│  # Yes – use Spacebar to select skills, Enter to confirm
│  # If unsure, skip for now – you can add skills later

◆  Preferred node manager for skill installs
│  ● npm
│  ○ pnpm
│  ○ bun

◆  Set GOOGLE_PLACES_API_KEY for goplaces?
│  ○ Yes / ● No
│  # Skip API key prompts unless you have them ready

◆  Enable hooks?
│  ◼ Skip for now
│  ◻ 🚀 boot-md
│  ◻ 📝 command-logger
│  ◻ 💾 session-memory
│  # Skip unless you've read the docs on hooks

◆  Install Gateway service (recommended)
│  ● Yes / ○ No
│  # Yes – installs a systemd unit for auto-start

◆  Gateway service runtime
│  ● Node (recommended) (Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.)
│  # Node – required for WhatsApp integration

◆  How do you want to hatch your bot?
│  ● Hatch in TUI (recommended)
│  ○ Open the Web UI
│  ○ Do this later
│  # TUI drops you into an interactive terminal to finish setup

Once complete, the gateway runs as a systemd service:

systemctl status clawdbot-gateway
journalctl -u clawdbot-gateway -f

WhatsApp Integration

I wanted Clawdbot as a proper personal assistant – something I can message from my phone without opening a laptop. WhatsApp Business works perfectly for this.

Don’t Use Your Real Number

Clawdbot needs to connect via WhatsApp Business API, which requires phone verification. Don’t use your personal number – if something goes wrong, you don’t want your main WhatsApp account locked.

Get a cheap SIM for SMS verification:

  • giffgaff – £10 gets you a SIM with a UK number, pay-as-you-go
  • Any budget MVNO works – you only need it for the initial SMS verification
  • Once verified, the SIM can sit in a drawer

Setup Steps

  1. Install WhatsApp Business on a spare phone (or use an Android emulator)
  2. Verify with your temporary number
  3. During Clawdbot onboarding, select WhatsApp as your chat channel
  4. Clawdbot generates a QR code – scan it with WhatsApp Business to link
  5. Message your bot with /start to pair the session

Now you can message Clawdbot from your main phone by adding the business number as a contact. It’s your 24/7 personal assistant – responds in seconds, runs automations, and doesn’t judge you for asking questions at 3am.

Security Checklist

What we’ve covered:

  • SSH key-only auth (password disabled)
  • Root login disabled
  • Non-root user with sudo
  • fail2ban with 24h bans for SSH brute force
  • UFW firewall (SSH only inbound)
  • Hetzner firewall (defence in depth)
  • Unattended security upgrades
  • Kernel hardening (IP spoofing, redirects, source routing)
  • Optional Tailscale for zero-trust access

What you should also consider:

  • SSH on non-standard port (security through obscurity, but reduces log noise)
  • Monitoring/alerting (Prometheus node_exporter, or just uptime-kuma)
  • Backup strategy for /home/clawdbot/clawd
  • Rate limiting at application level if exposing any HTTP endpoints

Gotchas

cloud-init timing – Terraform reports success before cloud-init finishes. The server is up, but hardening might still be in progress. Check /var/log/cloud-init-custom.log.

Tailscale SSH – If you enable tailscale up --ssh, Tailscale handles SSH auth separately. Your ~/.ssh/authorized_keys still works, but Tailscale ACLs take precedence for tailnet connections.

UFW vs Hetzner firewall – Both are active. Hetzner firewall filters at the hypervisor level (faster, can’t be bypassed from inside the VM). UFW runs inside the VM. Defence in depth – keep both.

npm global installs – If you hit EACCES errors, don’t use sudo npm. Fix npm’s directory:

mkdir ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc

Using Clawdbot

The dashboard is nice, but the chat interface is where it shines. Connect via Telegram (or your chosen channel), then just describe what you want.

I’ve set up:

  • Daily digest of bookmarked tweets via the bird skill
  • RSS feed monitoring with summaries pushed to a private channel
  • Automated Git repo health checks

The key insight: don’t configure workflows via the UI. Describe the outcome you want in natural language. Clawdbot figures out the skill configuration, proposes a plan, and executes it.


The full Terraform setup is on GitHub.

Questions? Find me on LinkedIn or drop a comment below.

Found this helpful?

Comments