Terraform modules for deploying OpenClaw on Hetzner Cloud. Includes VPS provisioning, firewall configuration, cloud-init automation, and deployment tooling.
This repository provides infrastructure-as-code for deploying OpenClaw—an open-source AI coding assistant—on a Hetzner Cloud VPS. The setup includes:
- Modular Terraform structure with remote S3 state backend
- Automated server provisioning via cloud-init
- Firewall configuration (UFW + Hetzner Cloud Firewall)
- Deployment scripts for application lifecycle management
- Backup and restore functionality
- SSH tunneling for secure gateway access
For information about OpenClaw itself, see the OpenClaw documentation.
- Terraform >= 1.5 (Installation Guide)
- Hetzner Cloud Account with API token (Console)
- Hetzner Object Storage for Terraform state (optional but recommended)
- SSH Key at
~/.ssh/id_rsa.pub - Docker configuration repo: openclaw-docker-config
git clone https://github.com/andreesg/openclaw-terraform-hetzner.git
cd openclaw-terraform-hetznercp config/inputs.example.sh config/inputs.sh
vim config/inputs.sh # Add your Hetzner API token and configurationRequired variables in config/inputs.sh:
HCLOUD_TOKEN- Hetzner Cloud API tokenTF_VAR_ssh_key_fingerprint- SSH key fingerprint from HetznerCONFIG_DIR- Path to your openclaw-docker-config repositorySERVER_IP- Address that scripts use to SSH into the VPS. Set toopenclaw-prodwhen using Tailscale (MagicDNS hostname, stable across rebuilds). Leave unset to auto-detect from Terraform output (only works when public SSH is open).
Tailscale (optional, recommended): Set
TF_VAR_enable_tailscale=trueandTF_VAR_tailscale_auth_keyto install Tailscale automatically on first boot — it lets you remove SSH from the public internet entirely. See Firewall Rules.
source config/inputs.sh
make init
make plan
make applymake bootstrap
make deploymake status
make logsAccess the gateway via SSH tunnel:
make tunnel # Opens tunnel on localhost:18789If you enabled Tailscale, confirm it connected before closing public SSH access:
make tailscale-status # node should appear as connected
make tailscale-ip # note your Tailscale IP (e.g. 100.x.x.x)┌─────────────────┐
│ Your Laptop │
│ │
│ ┌───────────┐ │ ┌─────────────────────┐
│ │ Terraform │──┼────────>│ Hetzner Cloud VPS │
│ └───────────┘ │ │ │
│ │ │ ┌──────────────┐ │
│ ┌───────────┐ │ │ │ Docker │ │
│ │ Config │──┼────────>│ │ OpenClaw │ │
│ │ Repo │ │ │ └──────────────┘ │
│ └───────────┘ │ │ │
└─────────────────┘ │ Firewall: SSH only │
└─────────────────────┘
│
v
┌─────────────────────┐
│ Hetzner Object │
│ Storage (state) │
└─────────────────────┘
| Component | Purpose | Location |
|---|---|---|
| infra/terraform/ | Infrastructure definitions | This repo |
| deploy/ | Deployment automation | This repo |
| docker/ | Container configuration | openclaw-docker-config |
| config/ | OpenClaw configuration | openclaw-docker-config |
Infrastructure:
make init # Initialize Terraform
make plan # Show infrastructure changes
make apply # Apply infrastructure changes
make destroy # Destroy all infrastructure
make output # Show Terraform outputsDeployment:
make bootstrap # Initial OpenClaw setup
make deploy # Pull latest image and restart
make status # Check deployment status
make logs # Stream container logsOperations:
make ssh # SSH to VPS as openclaw user
make tunnel # Create SSH tunnel to gateway
make backup-now # Trigger backup immediately
make restore # Restore from backup (BACKUP=filename)Tailscale:
make tailscale-status # Check Tailscale status (uses public IP — run before closing port 22)
make tailscale-ip # Get Tailscale IP (uses public IP — run before closing port 22)
make tailscale-up # Manually authenticate TailscaleConfiguration:
make push-env # Push environment variables
make push-config # Push OpenClaw configuration
make setup-auth # Configure Claude subscription authDefault: CX23 (2 vCPU, 4GB RAM)
To change server type, add to config/inputs.sh:
export TF_VAR_server_type="cx32" # 4 vCPU, 8GB RAMSee Hetzner server types.
By default SSH (port 22) is open to 0.0.0.0/0. Restrict this before going to production.
Option A — Restrict to your IP:
# In config/inputs.sh
export TF_VAR_ssh_allowed_cidrs='["203.0.113.50/32"]'Then apply:
source config/inputs.sh && make plan && make applyOption B — Tailscale VPN (recommended):
Tailscale creates a private WireGuard mesh so SSH is reachable only from devices on your tailnet — the public IP has no open SSH port.
-
Get an auth key at login.tailscale.com/admin/settings/keys — use reusable + pre-authorized keys, not ephemeral.
Auth key expiry: Reusable Tailscale auth keys expire after 90 days by default. Generate a fresh key at login.tailscale.com/admin/settings/keys and update
TF_VAR_tailscale_auth_keyinconfig/inputs.sh. -
Add to
config/inputs.sh:export TF_VAR_enable_tailscale=true export TF_VAR_tailscale_auth_key="tskey-auth-xxxxxxxxxxxxx"
-
Deploy. Tailscale is installed automatically on first boot. Then verify it's working before closing public access:
source config/inputs.sh && make plan && make apply make tailscale-status # confirm node is connected make tailscale-ip # note your Tailscale IP ssh openclaw@<tailscale-ip> # confirm Tailscale SSH works
-
Remove public SSH and point scripts at the Tailscale hostname:
# In config/inputs.sh export TF_VAR_ssh_allowed_cidrs='[]' export SERVER_IP="openclaw-prod" # Tailscale MagicDNS — stable across rebuilds source config/inputs.sh && make plan && make apply
Make sure to always source
config/inputs.shbefore runningmakecommands so the updatedSERVER_IPis used. -
Update
openclaw.jsonin your openclaw-docker-config repo to enable Tailscale-based gateway auth:{ "gateway": { "auth": { "allowTailscale": true }, "controlUi": { "allowInsecureAuth": true } } }Then push and restart:
make push-config deploy
allowTailscaleauthenticates dashboard users via Tailscale identity headers.allowInsecureAuthlets the control UI authenticate over plain HTTP — safe because it's only availale in your private tailnet.
After step 5, all make commands (make ssh, make deploy, make status, etc.) connect via openclaw-prod on your tailnet — no IP to track down.
Recovery: If Tailscale fails to connect, check status with
make tailscale-status. For persistent issues, you can delete the Hetzner Cloud Firewall via the console or re-runmake applyafter fixing the configuration.
The S3 backend configuration is commented out by default in infra/terraform/envs/prod/main.tf. To enable:
- Create Hetzner Object Storage bucket
- Set credentials in
config/inputs.sh:export AWS_ACCESS_KEY_ID="your-access-key" export AWS_SECRET_ACCESS_KEY="your-secret-key"
- Uncomment backend block in
main.tfand update endpoint URL - Run
terraform init -migrate-state
OpenClaw supports multiple AI providers. This setup defaults to Anthropic Claude, but you can switch to other providers by modifying the configuration in openclaw-docker-config.
Supported providers:
- Anthropic Claude (Opus, Sonnet, Haiku)
- OpenAI (GPT-4, GPT-3.5, o1)
- DeepSeek (V3, R1)
- Local models (via Ollama or LM Studio)
To switch providers:
-
Update
openclaw.jsonin the config repo:{ "agents": { "defaults": { "model": { "primary": "openai/gpt-4" } } }, "auth": { "profiles": { "openai:main": { "provider": "openai", "mode": "token" } } } } -
Update
secrets/openclaw.env:OPENAI_API_KEY=sk-...
-
Redeploy:
make push-config deploy
See OpenClaw provider documentation for detailed configuration.
# 1. Configure secrets
cp config/inputs.example.sh config/inputs.sh
vim config/inputs.sh
# 2. Deploy infrastructure
source config/inputs.sh
make init plan apply
# 3. Bootstrap application
make bootstrap
# 4. Deploy OpenClaw
make deploy
# 5. Verify
make status logs# Pull latest image and restart
make deploy
# Check logs
make logs# Edit openclaw.json in config repo
vim ~/path/to/openclaw-docker-config/config/openclaw.json
# Push and restart
make push-config deployBackups run daily at 02:00 UTC via systemd timer.
# Manual backup
make backup-now
# List backups
make ssh
ls -lh ~/backups/
# Restore from backup
make restore BACKUP=openclaw-backup-2026-02-08.tar.gzOpenClaw gateway runs on 127.0.0.1:18789 (localhost only) for security.
Access via SSH tunnel:
make tunnel # Creates tunnel: localhost:18789 -> VPS:18789Then open http://localhost:18789 in your browser. The gateway will ask for your Gateway Token — paste your OPENCLAW_GATEWAY_TOKEN value (from secrets/openclaw.env) into the settings field to authenticate.
Access via Tailscale Serve (if Tailscale is enabled):
ssh openclaw@<tailscale-ip>
sudo tailscale serve --bg 18789
sudo tailscale serve status # prints your HTTPS URLDashboard is then available at https://openclaw-prod.<tailnet>.ts.net from any device on your tailnet — no tunnel needed.
Note: Use Serve, not Funnel. Funnel makes the service publicly accessible on the internet. See OpenClaw Tailscale gateway docs for full configuration options including the
allowTailscalesetting.
Cause: S3 backend credentials not set
Solution:
source config/inputs.sh
make initOr use local state by commenting out the backend block in infra/terraform/envs/prod/main.tf.
Check logs:
make logs
make ssh
docker compose -f ~/openclaw/docker-compose.yml psCommon causes:
- Missing environment variables in
.env - Invalid OpenClaw configuration
- API key issues
Fix:
make push-env # Re-push environment variables
make push-config # Re-push OpenClaw config
make deploy # RestartCheck firewall rules:
grep TF_VAR_ssh_allowed_cidrs config/inputs.sh
# Check actual firewall
make ssh-root
ufw statusIf ssh_allowed_cidrs='[]' (Tailscale-only mode), make ssh connects via the public IP and will time out, that's expected. SSH via your Tailscale IP instead:
ssh openclaw@<tailscale-ip>Or - as stated above - use the SERVER_IP variable to point make ssh at the Tailscale hostname:
# In config/inputs.sh
export TF_VAR_ssh_allowed_cidrs='[]'
export SERVER_IP="openclaw-prod" # Tailscale MagicDNS — stable across rebuilds
source config/inputs.sh && make plan && make applyEmergency access: Hetzner web console → server → Console.
If you see Permission denied when creating directories under ~/.openclaw (e.g. during make setup-auth), Docker likely took ownership of the directory via the volume mount. This can happen if you ran make deploy before bootstrap finished, or if you're re-running bootstrap after a previous deploy.
Fix:
ssh openclaw@VPS_IP "sudo chown -R openclaw:openclaw ~/.openclaw"Then re-run make bootstrap or make setup-auth.
Verify prerequisites:
# Check CONFIG_DIR is set and exists
echo $CONFIG_DIR
ls $CONFIG_DIR/docker/docker-compose.yml
# Verify GHCR credentials
docker login ghcr.io -u YOUR_GITHUB_USERNAMECause: Destroyed and re-provisioned the VPS — new server has a different host key at the same public IP.
Error: WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
Fix:
ssh-keygen -R <old_vps_ip>
# Then retry — SSH will prompt you to accept the new key.Anthropic API key issues:
If using API key (not subscription):
# Check key is set
make ssh
grep ANTHROPIC_API_KEY ~/openclaw/.env
# Verify key has credits at console.anthropic.comIf using Claude subscription:
# Re-run setup-auth
make setup-auth
# Verify auth profile exists
make ssh
cat ~/.openclaw/agents/main/agent/auth-profiles.jsonSee SECURITY.md for the full security policy and threat model.
- Default allows SSH from anywhere (
0.0.0.0/0) — restrict before production - Option A: Restrict to your IP via
TF_VAR_ssh_allowed_cidrs - Option B: Enable Tailscale and set
ssh_allowed_cidrs='[]'— zero public SSH exposure - Use SSH keys, not passwords
- Rotate keys regularly
- See Firewall Rules for setup steps
- Never commit
config/inputs.shorsecrets/openclaw.env - Use environment variables for all credentials
- Rotate API tokens periodically
- Review
.gitignorebefore committing
- Gateway binds to
127.0.0.1(localhost only) — never exposed directly - Access via SSH tunnel or Tailscale Serve
- Review
infra/terraform/modules/hetzner-vps/main.tffor the full firewall rule set
- Monitor API usage and costs
- Set spending limits at provider dashboards
- Prefer subscription auth over API keys when available
- Never expose keys in logs or errors
- Keep Terraform providers updated
- Update OpenClaw regularly for security patches
- Monitor security advisories for dependencies
- Review cloud-init script before changes
.
├── infra/
│ ├── terraform/
│ │ ├── globals/ # Shared configuration
│ │ ├── envs/prod/ # Production environment
│ │ └── modules/ # Reusable modules
│ │ └── hetzner-vps/ # VPS module
│ └── cloud-init/
│ └── user-data.yml.tpl # Server initialization
├── deploy/ # Deployment scripts
│ ├── bootstrap.sh # Initial setup
│ ├── deploy.sh # Deploy/update
│ ├── backup.sh # Backup script
│ └── restore.sh # Restore script
├── scripts/ # Utility scripts
│ ├── push-env.sh # Push secrets to VPS
│ ├── push-config.sh # Push config to VPS
│ └── setup-auth.sh # Setup subscription auth
├── config/
│ └── inputs.example.sh # Configuration template
└── secrets/
└── openclaw.env.example # Secrets template
See Hetzner Cloud pricing for current rates. This setup uses a small shared VPS (default: CX23) plus minimal object storage for Terraform state.
Note: Prices exclude Anthropic/OpenAI API costs.
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
Ways to contribute:
- Report bugs via GitHub Issues
- Submit feature requests
- Improve documentation
- Submit pull requests
- Share your deployment experiences
- OpenClaw — The AI coding assistant this infrastructure deploys
- openclaw-docker-config — Docker and OpenClaw configuration (companion repo)
This project is licensed under the MIT License - see the LICENSE file for details.
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- OpenClaw Docs: docs.openclaw.ai