Production-grade Terraform + cloud-init setup for a hardened Hetzner VPS that can run OpenClaw 24/7.
This repository provisions and hardens a Hetzner host for private OpenClaw usage.
It includes:
- A Hetzner VPS
- Managed SSH key registration
- Locked-down Hetzner firewall
- First-boot hardening via cloud-init
- Tailscale bootstrap for private access
For a comprehensive beginner walkthrough (getting every terraform.tfvars value + full service setup), use:
docs/SETUP_FROM_ZERO.md
Before anything else, create your local variables file:
cp terraform.tfvars.example terraform.tfvars┌─────────────────────┐ ┌────────────────────────────────────────────┐
│ Your workstation │ │ Hetzner Cloud │
│ │ │ │
│ terraform apply ───┼─────────>│ VPS + cloud-init hardening + firewall │
│ │ │ │
│ SSH over Tailscale ┼<─────────┤ openclaw user + Tailscale node │
│ │ │ │
└─────────────────────┘ └────────────────────────────────────────────┘
hcloud_ssh_key.defaulthcloud_server.vpshcloud_firewall.vpshcloud_firewall_attachment.vps
- System updates and security packages
- Non-root sudo user creation
- SSH key setup for that user
- SSH hardening (no password auth, no root login, limits/timeouts)
fail2banwith UFW integration- UFW configured with deny-by-default inbound policy
- Unattended security upgrades
- Sysctl network hardening
- Tailscale installation and join
.
├── data.tf
├── main.tf
├── outputs.tf
├── variables.tf
├── terraform.tfvars.example
└── scripts/
└── cloud-init.sh
- Terraform
>= 1.6.0- Install:
https://developer.hashicorp.com/terraform/install
- Install:
- Hetzner Cloud project + API token
- One SSH public key ready
- Tailscale account + auth key
Early in the setup, set up Tailscale on your laptop (install client, sign in, and confirm your laptop appears in Tailscale Machines). Tailscale is a private mesh VPN that lets you reach your VPS securely without exposing SSH publicly. For this project, the free Tailscale plan is usually enough.
-
Copy example variables:
cp terraform.tfvars.example terraform.tfvars
-
Edit
terraform.tfvarswith your values:hcloud_tokenssh_public_keyallowed_ssh_ips([]for Tailscale-only, or your public IP CIDR for fallback)tailscale_auth_key
-
Deploy:
terraform init terraform validate terraform plan terraform apply
-
Wait for cloud-init to complete (Terraform finishes before hardening is done):
Preferred (Tailscale-first): SSH using the node's Tailscale IP or MagicDNS name after it appears in Tailscale Machines.
ssh openclaw@<tailscale-ip-or-magicdns-name> 'tail -f /var/log/cloud-init-custom.log'
First connection note: SSH may ask to trust the host key (
The authenticity of host ... can't be established). Typeyesonce to continue.Fallback (only if you set public SSH CIDR in
allowed_ssh_ips):ssh openclaw@$(terraform output -raw server_ip) 'tail -f /var/log/cloud-init-custom.log'
Use the full guide here:
docs/SETUP_FROM_ZERO.md
It includes account setup, collecting all terraform.tfvars values, server size selection, deployment, and post-deploy service setup.
Use this checklist when someone asks: "where do I get each value?"
hcloud_token- Source: Hetzner project -> Security -> API Tokens.
- Format: starts with
hcloud_. - Required: yes.
server_name- Source: you choose.
- Recommendation:
openclaw-prod. - Required: no (has default).
server_type- Source: you choose based on size/cost.
- Recommendation:
cx22to start. - Required: no (has default).
image- Source: Hetzner OS image naming.
- Recommendation:
ubuntu-24.04. - Required: no (has default).
location- Source: Hetzner datacenter slug.
- Recommendation:
nbg1or nearest region. - Required: no (has default).
ssh_public_key- Source: your local
~/.ssh/*.pubkey contents. - Required: yes.
- Source: your local
allowed_ssh_ips- Source: your public IP (
curl -4 ifconfig.me) or VPN CIDR. - Required: no. With required Tailscale, you can set
[]and use tailnet SSH only.
- Source: your public IP (
tailscale_auth_key- Source: Tailscale Admin -> Settings -> Keys.
- Required: yes.
hcloud_token = "your-hetzner-api-token"
server_name = "openclaw-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 range
allowed_ssh_ips = []
tailscale_auth_key = "tskey-auth-xxxxx"-
Create/sign in to Tailscale.
-
Open Admin Console -> Settings -> Keys.
-
Generate auth key with:
- Description:
Terraform OpenClaw - Reusable: Off
- Expiration: short (for example 1 day)
- Ephemeral: Off
- Tags: Off (unless you use tagged-node ACLs)
- Description:
-
Set:
tailscale_auth_key = "tskey-auth-..."
Why this helps:
- Keep OpenClaw bound to
127.0.0.1 - Access privately over tailnet HTTPS
- No public dashboard exposure, no port forwarding
- No need to know the node's Tailscale IP before provisioning
If your tailnet requires device approval, approve the new node in Machines after bootstrap.
This project depends on three services. Use these mini-guides for teammates who are brand new.
- Create Hetzner account.
- Create a project dedicated to this environment.
- Create API token with Read & Write.
- Add billing method so provisioning can succeed.
- Keep token in password manager; do not commit it.
- Create Tailscale account.
- Install Tailscale client on your laptop/phone.
- Confirm your device appears in Machines.
- Generate auth key for server bootstrap.
- Put key in
terraform.tfvarsastailscale_auth_key. - After deploy, verify the VPS appears in your tailnet.
Use one of these as <tailscale-ip-or-magicdns-name>:
-
Tailscale Admin -> Machines:
- open your new server entry
- copy either:
- the Tailscale IPv4 (usually
100.x.y.z), or - the MagicDNS hostname (for example
openclaw-prod.tail1234.ts.net)
- the Tailscale IPv4 (usually
-
On the VPS (if already connected another way), run:
tailscale ip -4 tailscale status
Then SSH with:
ssh openclaw@<tailscale-ip-or-magicdns-name>On first SSH connection, confirm host key trust prompt with yes.
- SSH to VPS as
openclaw. - Install Node (
nvm) and optionally Homebrew. - Install OpenClaw globally:
npm i -g openclaw. - Run
openclaw onboardin Manual mode. - Keep current default model (
openai-codex/gpt-5.3-codex). - Set workspace directory to
/home/openclaw/.openclaw/workspacewhen prompted. - Keep gateway port at
18789. - Set gateway bind to Loopback (
127.0.0.1). - Use gateway auth: Token.
- Use Tailscale exposure: Serve.
- Set
Reset Tailscale serve/funnel on exit?toNo. - For
Gateway token (blank to generate), leave it blank to auto-generate. - For
Configure skills now? (recommended), chooseNo. - For
Enable hooks?, chooseSkip for now. - Install systemd service when prompted.
- Verify with
systemctl --user status openclaw-gateway.
SSH in:
ssh openclaw@<tailscale-ip-or-magicdns-name>Fallback if public SSH is enabled:
ssh openclaw@$(terraform output -raw server_ip)Install Node via nvm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.bashrc
nvm install 24
node -vInstall Homebrew (if required by selected skills):
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' >> ~/.bashrc
source ~/.bashrcInstall OpenClaw:
npm i -g openclawIf npm global install fails with permissions (EACCES), use a user prefix:
mkdir ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrcRun onboarding:
openclaw onboardSuggested onboarding choices:
- Manual mode
- Local gateway on this VPS
- Default model: Keep current (
openai-codex/gpt-5.3-codex) - Workspace directory:
/home/openclaw/.openclaw/workspace - Gateway port:
18789 - Gateway bind: Loopback (
127.0.0.1) - Gateway auth: Token
- Tailscale exposure: Serve (not Funnel)
- Reset Tailscale serve/funnel on exit?:
No - Gateway token (blank to generate): leave blank (auto-generate)
- Configure skills now? (recommended):
No - Enable hooks?:
Skip for now - Install systemd service
- Runtime: Node
After apply:
server_ip: public IPv4server_ipv6: public IPv6ssh_command: public-IP SSH command (fallback if public SSH is enabled)
Use:
terraform output
terraform output -raw server_ipWith the recommended setup (Gateway bind: Loopback + Tailscale exposure: Serve), open the dashboard from your laptop using the tailnet URL:
openclaw statusFrom the Tailscale line, open the HTTPS URL (for example: https://openclaw-prod.tailfc18f0.ts.net).
You can also open/print the dashboard link directly with:
openclaw dashboardIf you prefer an SSH tunnel fallback instead of Tailscale Serve:
ssh -N -L 18789:127.0.0.1:18789 openclaw@<tailscale-ip-or-magicdns-name>Then open:
http://127.0.0.1:18789
If the dashboard shows disconnected (1008): pairing required, approve the device on the VPS:
# On the VPS
openclaw devices list
openclaw devices approve <requestId>Then refresh the dashboard page.
If the UI asks for auth, fetch your gateway token on the VPS and paste it in dashboard settings:
openclaw config get gateway.auth.tokenRun these on the VPS to confirm gateway health and tailnet exposure:
openclaw status
systemctl --user status openclaw-gateway
tailscale serve statusExpected indicators:
Gateway service:systemd installed · enabled · runningTailscale: includes your tailnet URLtailscale serve status: shows proxy tohttp://127.0.0.1:18789
If you want to enable Telegram right away:
openclaw channels addRecommended wizard choices:
- Configure chat channels now?:
Yes - Select a channel:
Telegram (Bot API) - Telegram account:
default (primary) - Enter Telegram bot token from
@BotFather - Configure DM access policies now?:
Yes - Telegram DM policy:
Pairing (recommended)
When a Telegram user starts a chat, approve pairing with:
openclaw pairing approve telegram <code>You can inspect channel state anytime:
openclaw channels list
openclaw channels status --deepCheck status:
systemctl --user status openclaw-gatewayFollow logs:
journalctl --user -u openclaw-gateway -f- Use a dedicated number for WhatsApp Business onboarding.
- Verify once, then pair with QR during OpenClaw setup.
- Send
/startto initialize session.
Defence in depth:
- Hetzner firewall at hypervisor level
- UFW inside VM
- fail2ban for SSH brute-force mitigation
- hardened SSH config
- unattended security patches
- sysctl hardening for common network attack patterns
-
Cloud-init timing: Terraform may succeed before hardening finishes.
-
Tailscale SSH behavior: tailnet ACLs can affect SSH behavior when using
tailscale up --ssh. -
Tailscale Serve permissions: new deployments set
tailscaleoperator permissions foropenclawautomatically via cloud-init. For older deployments, run once:sudo tailscale set --operator=openclawIf Serve is disabled at tailnet level, enable it in Tailscale admin first, then run
tailscale serve --bg --yes 18789. -
openclaw statussecurity warnings: seeingReverse proxy headers are not trustedis expected when using loopback bind without a custom reverse proxy. ThedenyCommands entries are ineffectivewarning is from OpenClaw command-name validation; review and tighten command names later if you customizegateway.nodes.denyCommands. -
Dual firewalls: Hetzner firewall + UFW are both active by design.
-
Homebrew sudo prompt edge case: if Brew still prompts for a sudo password, add per-user sudo defaults and retry:
sudo tee /etc/sudoers.d/openclaw >/dev/null <<'EOF' Defaults:openclaw verifypw=any Defaults:openclaw listpw=never openclaw ALL=(ALL:ALL) NOPASSWD:ALL EOF sudo chmod 0440 /etc/sudoers.d/openclaw sudo visudo -c NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- Add backup strategy for
/home/openclaw/clawd - Add monitoring/alerting (for example, Uptime Kuma or node exporter)
- Consider non-standard SSH port (mainly reduces log noise)
- Add app-layer rate limits if exposing HTTP endpoints
Destroy everything:
terraform destroy