2026-03-23 18:31:11 +01:00
#!/usr/bin/env bash
# deploy/hetzner.sh — One-click Hetzner VPS deployment for webclaw
#
# Creates a Hetzner Cloud VPS with Docker, deploys webclaw + Ollama,
# and optionally configures nginx + SSL.
#
# Usage:
# ./deploy/hetzner.sh # Interactive setup
# ./deploy/hetzner.sh --destroy # Tear down the server
#
# Server type recommendations:
# cpx11: 2 vCPU, 2GB RAM, ~4.59 EUR/mo — Minimum (scraping only, no LLM)
# cpx21: 3 vCPU, 4GB RAM, ~8.49 EUR/mo — Recommended (scraping + small LLM)
# cpx31: 4 vCPU, 8GB RAM, ~15.59 EUR/mo — Best (scraping + LLM + high concurrency)
# cpx41: 8 vCPU, 16GB RAM, ~28.19 EUR/mo — Heavy use (high-volume crawling)
set -euo pipefail
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
HETZNER_API = "https://api.hetzner.cloud/v1"
SERVER_NAME = "webclaw"
REPO_URL = "https://github.com/0xMassi/webclaw.git"
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
RED = '\033[0;31m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
BLUE = '\033[0;34m'
CYAN = '\033[0;36m'
BOLD = '\033[1m'
DIM = '\033[2m'
RESET = '\033[0m'
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
info( ) { printf " ${ BLUE } [*] ${ RESET } %s\n " " $* " ; }
success( ) { printf " ${ GREEN } [+] ${ RESET } %s\n " " $* " ; }
warn( ) { printf " ${ YELLOW } [!] ${ RESET } %s\n " " $* " ; }
error( ) { printf " ${ RED } [x] ${ RESET } %s\n " " $* " >& 2; }
fatal( ) { error " $* " ; exit 1; }
2026-05-19 17:03:52 +02:00
# Mask a secret for display: keep the last 4 chars, redact the rest.
# Empty input renders as "(not set)".
mask_secret( ) {
local s = " $1 "
if [ [ -z " $s " ] ] ; then
printf '(not set)'
elif ( ( ${# s } <= 4 ) ) ; then
printf '****'
else
printf '****%s' " ${ s : -4 } "
fi
}
2026-03-23 18:31:11 +01:00
prompt( ) {
local var_name = " $1 " prompt_text = " $2 " default = " ${ 3 :- } "
if [ [ -n " $default " ] ] ; then
printf " ${ CYAN } %s ${ DIM } [%s] ${ RESET } : " " $prompt_text " " $default "
else
printf " ${ CYAN } %s ${ RESET } : " " $prompt_text "
fi
read -r input
2026-05-19 17:03:52 +02:00
printf -v " $var_name " '%s' " ${ input :- $default } "
2026-03-23 18:31:11 +01:00
}
prompt_secret( ) {
local var_name = " $1 " prompt_text = " $2 " default = " ${ 3 :- } "
if [ [ -n " $default " ] ] ; then
printf " ${ CYAN } %s ${ DIM } [%s] ${ RESET } : " " $prompt_text " " $default "
else
printf " ${ CYAN } %s ${ RESET } : " " $prompt_text "
fi
read -rs input
echo
2026-05-19 17:03:52 +02:00
printf -v " $var_name " '%s' " ${ input :- $default } "
2026-03-23 18:31:11 +01:00
}
generate_key( ) {
# 32-char random hex key
if command -v openssl & >/dev/null; then
openssl rand -hex 16
else
LC_ALL = C tr -dc 'a-f0-9' < /dev/urandom | head -c 32
fi
}
hetzner_api( ) {
local method = " $1 " path = " $2 "
shift 2
curl -sf -X " $method " \
-H " Authorization: Bearer $HETZNER_TOKEN " \
-H "Content-Type: application/json" \
" $HETZNER_API $path " \
" $@ "
}
# ---------------------------------------------------------------------------
# Preflight checks
# ---------------------------------------------------------------------------
preflight( ) {
local missing = ( )
command -v curl & >/dev/null || missing += ( "curl" )
command -v jq & >/dev/null || missing += ( "jq" )
command -v ssh & >/dev/null || missing += ( "ssh" )
if [ [ ${# missing [@] } -gt 0 ] ] ; then
fatal " Missing required tools: ${ missing [*] } . Install them and try again. "
fi
}
# ---------------------------------------------------------------------------
# Validate Hetzner token
# ---------------------------------------------------------------------------
validate_token( ) {
info "Validating Hetzner API token..."
local response
response = $( hetzner_api GET "/servers?per_page=1" 2>& 1) || {
fatal "Invalid Hetzner API token. Get one at: https://console.hetzner.cloud"
}
success "Token is valid."
}
# ---------------------------------------------------------------------------
# Check if server already exists
# ---------------------------------------------------------------------------
find_server( ) {
local response
response = $( hetzner_api GET " /servers?name= $SERVER_NAME " )
echo " $response " | jq -r '.servers[0] // empty'
}
get_server_id( ) {
local server
server = $( find_server)
if [ [ -n " $server " && " $server " != "null" ] ] ; then
echo " $server " | jq -r '.id'
fi
}
get_server_ip( ) {
local server
server = $( find_server)
if [ [ -n " $server " && " $server " != "null" ] ] ; then
echo " $server " | jq -r '.public_net.ipv4.ip'
fi
}
# ---------------------------------------------------------------------------
# Destroy mode
# ---------------------------------------------------------------------------
destroy_server( ) {
info "Looking for existing webclaw server..."
local server_id
server_id = $( get_server_id)
if [ [ -z " $server_id " ] ] ; then
warn " No server named ' $SERVER_NAME ' found. Nothing to destroy. "
exit 0
fi
local ip
ip = $( get_server_ip)
warn " Found server: $SERVER_NAME (ID: $server_id , IP: $ip ) "
printf " ${ RED } This will permanently delete the server and all its data. ${ RESET } \n "
printf " ${ CYAN } Type 'destroy' to confirm ${ RESET } : "
read -r confirmation
if [ [ " $confirmation " != "destroy" ] ] ; then
info "Aborted."
exit 0
fi
info " Destroying server $server_id ... "
hetzner_api DELETE " /servers/ $server_id " > /dev/null
success "Server destroyed."
# Clean SSH known_hosts
if [ [ -n " $ip " ] ] ; then
ssh-keygen -R " $ip " 2>/dev/null || true
info " Removed $ip from SSH known_hosts. "
fi
}
# ---------------------------------------------------------------------------
# Build cloud-init user_data
# ---------------------------------------------------------------------------
build_cloud_init( ) {
local auth_key = " $1 " openai_key = " $2 " anthropic_key = " $3 " domain = " $4 " ollama_model = " $5 "
# Build .env content
local env_content = " # webclaw deployment — generated by hetzner.sh
WEBCLAW_HOST = 0.0.0.0
WEBCLAW_PORT = 3000
WEBCLAW_AUTH_KEY = $auth_key
OLLAMA_HOST = http://ollama:11434
OLLAMA_MODEL = $ollama_model
WEBCLAW_LOG = info"
if [ [ -n " $openai_key " ] ] ; then
env_content = " $env_content
OPENAI_API_KEY = $openai_key "
fi
if [ [ -n " $anthropic_key " ] ] ; then
env_content = " $env_content
ANTHROPIC_API_KEY = $anthropic_key "
fi
# Nginx + certbot block (only if domain provided)
local nginx_block = ""
if [ [ -n " $domain " ] ] ; then
nginx_block = "
# --- Nginx reverse proxy + SSL ---
- apt-get install -y nginx certbot python3-certbot-nginx
- |
cat > /etc/nginx/sites-available/webclaw <<'NGIN X'
server {
listen 80;
server_name $domain ;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host \$ host;
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;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
}
NGINX
- ln -sf /etc/nginx/sites-available/webclaw /etc/nginx/sites-enabled/webclaw
- rm -f /etc/nginx/sites-enabled/default
- systemctl restart nginx
# SSL cert (will fail silently if DNS not pointed yet)
- certbot --nginx -d $domain --non-interactive --agree-tos --register-unsolicited-contact -m admin@$domain || echo 'Certbot failed — point DNS to this IP and run: certbot --nginx -d $domain'
"
fi
cat <<CLOUDINIT
#cloud-config
package_update: true
runcmd:
# --- Firewall ---
- ufw allow 22/tcp
- ufw allow 80/tcp
- ufw allow 443/tcp
- ufw allow 3000/tcp
- ufw --force enable
# --- Docker (already installed on hetzner docker-ce image, but ensure compose) ---
- |
if ! command -v docker & >/dev/null; then
curl -fsSL https://get.docker.com | sh
fi
- |
if ! docker compose version & >/dev/null; then
apt-get install -y docker-compose-plugin
fi
# --- Clone and deploy ---
- git clone $REPO_URL /opt/webclaw
- |
cat > /opt/webclaw/.env <<'DOTENV'
$env_content
DOTENV
# Remove leading whitespace from heredoc
sed -i 's/^ //' /opt/webclaw/.env
$nginx_block
# --- Start services ---
- cd /opt/webclaw && docker compose up -d --build
# --- Pull Ollama model in background (non-blocking) ---
- |
nohup bash -c '
echo "Waiting for Ollama to start..."
for i in \$ ( seq 1 60) ; do
if docker compose -f /opt/webclaw/docker-compose.yml exec -T ollama ollama list & >/dev/null; then
echo " Ollama ready. Pulling $ollama_model ... "
docker compose -f /opt/webclaw/docker-compose.yml exec -T ollama ollama pull $ollama_model
echo " Model $ollama_model pulled. "
break
fi
sleep 5
done
' > /var/log/ollama-pull.log 2>& 1 &
CLOUDINIT
}
# ---------------------------------------------------------------------------
# Wait for SSH
# ---------------------------------------------------------------------------
wait_for_ssh( ) {
local ip = " $1 " max_attempts = 40
info "Waiting for server to become reachable (this takes 1-3 minutes)..."
for i in $( seq 1 $max_attempts ) ; do
if ssh -o ConnectTimeout = 3 -o StrictHostKeyChecking = no -o BatchMode = yes \
" root@ $ip " "echo ok" & >/dev/null; then
return 0
fi
printf "."
sleep 5
done
echo
return 1
}
# ---------------------------------------------------------------------------
# Wait for Docker build to complete
# ---------------------------------------------------------------------------
wait_for_docker( ) {
local ip = " $1 " max_attempts = 60
info "Waiting for Docker build to complete (this takes 5-15 minutes on first deploy)..."
for i in $( seq 1 $max_attempts ) ; do
local status
status = $( ssh -o ConnectTimeout = 5 -o StrictHostKeyChecking = no \
" root@ $ip " "docker ps --filter name=webclaw --format '{{.Status}}' 2>/dev/null | head -1" 2>/dev/null || echo "" )
if [ [ " $status " = = *"Up" * ] ] ; then
return 0
fi
printf "."
sleep 15
done
echo
return 1
}
# ---------------------------------------------------------------------------
# Get SSH keys from Hetzner account
# ---------------------------------------------------------------------------
get_ssh_keys( ) {
local response
response = $( hetzner_api GET "/ssh_keys" )
echo " $response " | jq -r '[.ssh_keys[].id] // []'
}
# ---------------------------------------------------------------------------
# Main: create server
# ---------------------------------------------------------------------------
create_server( ) {
# Check for existing server
local existing_id
existing_id = $( get_server_id)
if [ [ -n " $existing_id " ] ] ; then
local existing_ip
existing_ip = $( get_server_ip)
warn " Server ' $SERVER_NAME ' already exists (ID: $existing_id , IP: $existing_ip ) "
warn "Run with --destroy first, or use a different name."
exit 1
fi
# Gather configuration
echo
printf " ${ BOLD } ${ GREEN } webclaw Hetzner Deploy ${ RESET } \n "
printf " ${ DIM } One-click VPS deployment for webclaw REST API + Ollama ${ RESET } \n "
echo
prompt SERVER_TYPE "Server type (cpx11/cpx21/cpx31/cpx41)" "cpx21"
prompt LOCATION "Region (fsn1/nbg1/hel1/ash/hil)" "fsn1"
prompt DOMAIN "Domain for SSL (leave empty to skip)" ""
prompt_secret OPENAI_KEY "OpenAI API key (optional)" ""
prompt_secret ANTHROPIC_KEY "Anthropic API key (optional)" ""
local generated_auth_key
generated_auth_key = $( generate_key)
prompt_secret AUTH_KEY "Webclaw auth key" " $generated_auth_key "
prompt OLLAMA_MODEL "Ollama model to pre-pull" "qwen3:1.7b"
echo
info "Configuration:"
printf " Server type: ${ BOLD } %s ${ RESET } \n " " $SERVER_TYPE "
printf " Region: ${ BOLD } %s ${ RESET } \n " " $LOCATION "
printf " Domain: ${ BOLD } %s ${ RESET } \n " " ${ DOMAIN :- none } "
printf " OpenAI key: ${ BOLD } %s ${ RESET } \n " " $( [ -n " $OPENAI_KEY " ] && echo 'set' || echo 'not set' ) "
printf " Anthropic key: ${ BOLD } %s ${ RESET } \n " " $( [ -n " $ANTHROPIC_KEY " ] && echo 'set' || echo 'not set' ) "
2026-05-19 17:03:52 +02:00
printf " Auth key: ${ BOLD } %s ${ RESET } \n " " $( mask_secret " $AUTH_KEY " ) "
2026-03-23 18:31:11 +01:00
printf " Ollama model: ${ BOLD } %s ${ RESET } \n " " $OLLAMA_MODEL "
echo
printf " ${ CYAN } Proceed? (y/n) ${ RESET } : "
read -r confirm
[ [ " $confirm " = ~ ^[ Yy] $ ] ] || { info "Aborted." ; exit 0; }
# Build cloud-init
local user_data
user_data = $( build_cloud_init " $AUTH_KEY " " $OPENAI_KEY " " $ANTHROPIC_KEY " " $DOMAIN " " $OLLAMA_MODEL " )
# Get SSH keys
local ssh_keys
ssh_keys = $( get_ssh_keys)
info " Found $( echo " $ssh_keys " | jq length) SSH key(s) in your Hetzner account. "
# Create server
info " Creating $SERVER_TYPE server in $LOCATION ... "
local create_payload
create_payload = $( jq -n \
--arg name " $SERVER_NAME " \
--arg server_type " $SERVER_TYPE " \
--arg location " $LOCATION " \
--arg user_data " $user_data " \
--argjson ssh_keys " $ssh_keys " \
' {
name: $name ,
server_type: $server_type ,
location: $location ,
image: "docker-ce" ,
ssh_keys: $ssh_keys ,
user_data: $user_data ,
public_net: {
enable_ipv4: true,
enable_ipv6: true
}
} ' )
local response
response = $( hetzner_api POST "/servers" -d " $create_payload " ) || {
fatal "Failed to create server. Check your Hetzner token permissions."
}
local server_id server_ip root_password
server_id = $( echo " $response " | jq -r '.server.id' )
server_ip = $( echo " $response " | jq -r '.server.public_net.ipv4.ip' )
root_password = $( echo " $response " | jq -r '.root_password // empty' )
if [ [ -z " $server_id " || " $server_id " = = "null" ] ] ; then
error "Server creation response:"
echo " $response " | jq .
fatal "Failed to create server."
fi
success " Server created: ID= $server_id , IP= $server_ip "
if [ [ -n " $root_password " ] ] ; then
echo
warn " Root password (save this, shown only once): $root_password "
echo
fi
# Wait for SSH
if wait_for_ssh " $server_ip " ; then
success "Server is reachable via SSH."
else
warn "Server not yet reachable via SSH. It may still be booting."
warn " Try: ssh root@ $server_ip "
fi
# Summary
echo
printf " ${ BOLD } ${ GREEN } Deployment started. ${ RESET } \n "
echo
printf " The server is now building webclaw from source.\n"
printf " This takes ${ BOLD } 5-15 minutes ${ RESET } on first deploy.\n "
echo
printf " ${ BOLD } Server IP: ${ RESET } %s\n " " $server_ip "
printf " ${ BOLD } SSH: ${ RESET } ssh root@%s\n " " $server_ip "
2026-05-19 17:03:52 +02:00
printf " ${ BOLD } Auth key: ${ RESET } %s\n " " $( mask_secret " $AUTH_KEY " ) "
printf " ${ DIM } (full key stored in /opt/webclaw/.env on the server:\n "
printf " ssh root@%s 'grep WEBCLAW_AUTH_KEY /opt/webclaw/.env') ${ RESET } \n " " $server_ip "
2026-03-23 18:31:11 +01:00
echo
printf " ${ BOLD } Monitor build progress: ${ RESET } \n "
printf " ssh root@%s 'cd /opt/webclaw && docker compose logs -f'\n" " $server_ip "
echo
printf " ${ BOLD } Test when ready: ${ RESET } \n "
printf " curl http://%s:3000/health\n" " $server_ip "
echo
printf " ${ BOLD } Scrape: ${ RESET } \n "
printf " curl -X POST http://%s:3000/v1/scrape \\\\\n" " $server_ip "
printf " -H 'Content-Type: application/json' \\\\\n"
2026-05-19 17:03:52 +02:00
printf " -H 'Authorization: Bearer <YOUR_AUTH_KEY>' \\\\\n"
2026-03-23 18:31:11 +01:00
printf " -d '{\"url\": \"https://example.com\"}'\n"
echo
if [ [ -n " $DOMAIN " ] ] ; then
printf " ${ BOLD } Domain: ${ RESET } \n "
printf " Point %s A record -> %s\n" " $DOMAIN " " $server_ip "
printf " SSL will auto-configure via certbot.\n"
printf " Then: curl https://%s/health\n" " $DOMAIN "
echo
fi
printf " ${ BOLD } Pull Ollama model manually (if auto-pull hasn't finished): ${ RESET } \n "
printf " ssh root@%s 'cd /opt/webclaw && docker compose exec ollama ollama pull %s'\n" " $server_ip " " $OLLAMA_MODEL "
echo
printf " ${ BOLD } Tear down: ${ RESET } \n "
2026-05-19 17:03:52 +02:00
printf " HETZNER_TOKEN=\$HETZNER_TOKEN ./deploy/hetzner.sh --destroy\n"
printf " ${ DIM } (re-export the same token you used to deploy) ${ RESET } \n "
2026-03-23 18:31:11 +01:00
echo
}
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
main( ) {
preflight
# Accept token from env or prompt
if [ [ -z " ${ HETZNER_TOKEN :- } " ] ] ; then
echo
printf " ${ BOLD } ${ GREEN } webclaw Hetzner Deploy ${ RESET } \n "
echo
prompt_secret HETZNER_TOKEN "Hetzner API token (https://console.hetzner.cloud)" ""
[ [ -n " $HETZNER_TOKEN " ] ] || fatal "Hetzner API token is required."
fi
validate_token
if [ [ " ${ 1 :- } " = = "--destroy" ] ] ; then
destroy_server
else
create_server
fi
}
main " $@ "