this repo has no description
1#!/usr/bin/env bash
2# Single-command deployment for assistant to Hetzner Cloud
3#
4# Prerequisites:
5# - hcloud CLI installed and configured (hcloud context create assistant)
6# - secrets.env filled in (cp secrets.env.example secrets.env)
7#
8# Usage:
9# ./deploy.sh
10
11set -euo pipefail
12IFS=$'\n\t'
13
14# Safe temp directory with automatic cleanup
15scratch=$(mktemp -d -t deploy.XXXXXXXXXX)
16function finish {
17 rm -rf "$scratch"
18}
19trap finish EXIT
20
21# Colors for output
22RED='\033[0;31m'
23GREEN='\033[0;32m'
24YELLOW='\033[0;33m'
25BLUE='\033[0;34m'
26NC='\033[0m' # No Color
27
28# Script directory
29SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
31# Server name
32SERVER_NAME="assistant"
33
34#######################################
35# Logging functions
36#######################################
37log_info() {
38 echo -e "${BLUE}[INFO]${NC} $1"
39}
40
41log_success() {
42 echo -e "${GREEN}[OK]${NC} $1"
43}
44
45log_warn() {
46 echo -e "${YELLOW}[WARN]${NC} $1"
47}
48
49log_error() {
50 echo -e "${RED}[ERROR]${NC} $1" >&2
51}
52
53die() {
54 log_error "$1"
55 exit 1
56}
57
58#######################################
59# Check prerequisites
60#######################################
61check_prerequisites() {
62 log_info "Checking prerequisites..."
63
64 # Check required commands
65 local missing=()
66 for cmd in hcloud jq curl ssh scp; do
67 if ! command -v "$cmd" &> /dev/null; then
68 missing+=("$cmd")
69 fi
70 done
71
72 if [[ ${#missing[@]} -gt 0 ]]; then
73 die "Missing required commands: ${missing[*]}"
74 fi
75
76 # Check hcloud is configured
77 if ! hcloud context active &> /dev/null; then
78 die "hcloud not configured. Run: hcloud context create assistant"
79 fi
80
81 # Check secrets.env exists
82 if [[ ! -f "$SCRIPT_DIR/secrets.env" ]]; then
83 die "secrets.env not found. Run: cp secrets.env.example secrets.env"
84 fi
85
86 log_success "Prerequisites OK"
87}
88
89#######################################
90# Load and validate secrets
91#######################################
92load_secrets() {
93 log_info "Loading secrets..."
94
95 # shellcheck source=/dev/null
96 source "$SCRIPT_DIR/secrets.env"
97
98 # Required variables
99 local required=(
100 DOMAIN
101 SUBDOMAIN
102 CLOUDFLARE_API_TOKEN
103 CLOUDFLARE_ZONE_ID
104 TS_OAUTH_SECRET
105 TS_TAG
106 GH_REPO
107 TELEGRAM_BOT_TOKEN
108 OPENAI_API_KEY
109 )
110
111 local missing=()
112 for var in "${required[@]}"; do
113 if [[ -z "${!var:-}" ]]; then
114 missing+=("$var")
115 fi
116 done
117
118 if [[ ${#missing[@]} -gt 0 ]]; then
119 die "Missing required secrets: ${missing[*]}"
120 fi
121
122 # Generate optional secrets if empty
123 if [[ -z "${ANTHROPIC_PROXY_SESSION_SECRET:-}" ]]; then
124 ANTHROPIC_PROXY_SESSION_SECRET=$(openssl rand -hex 32)
125 log_info "Generated ANTHROPIC_PROXY_SESSION_SECRET"
126 fi
127
128 if [[ -z "${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}" ]]; then
129 TELEGRAM_WEBHOOK_SECRET_TOKEN=$(openssl rand -hex 16)
130 log_info "Generated TELEGRAM_WEBHOOK_SECRET_TOKEN"
131 fi
132
133 if [[ -z "${LETTA_SERVER_PASSWORD:-}" ]]; then
134 LETTA_SERVER_PASSWORD=$(openssl rand -hex 16)
135 log_info "Generated LETTA_SERVER_PASSWORD"
136 fi
137
138 # Defaults
139 HETZNER_LOCATION="${HETZNER_LOCATION:-fsn1}"
140 HETZNER_SERVER_TYPE="${HETZNER_SERVER_TYPE:-cx22}"
141
142 log_success "Secrets loaded"
143}
144
145#######################################
146# Create or get SSH key
147#######################################
148setup_ssh_key() {
149 log_info "Setting up SSH key..."
150
151 local key_name="assistant-deploy"
152
153 # Check if key exists in hcloud
154 if hcloud ssh-key describe "$key_name" &> /dev/null; then
155 log_info "Using existing SSH key: $key_name"
156 SSH_KEY_NAME="$key_name"
157 return
158 fi
159
160 # Check if local key exists
161 local local_key="$HOME/.ssh/id_ed25519"
162 if [[ ! -f "$local_key" ]]; then
163 local_key="$HOME/.ssh/id_rsa"
164 fi
165
166 if [[ ! -f "$local_key" ]]; then
167 die "No SSH key found. Create one with: ssh-keygen -t ed25519"
168 fi
169
170 # Upload to hcloud
171 hcloud ssh-key create --name "$key_name" --public-key-from-file "${local_key}.pub"
172 SSH_KEY_NAME="$key_name"
173 log_success "SSH key uploaded: $key_name"
174}
175
176#######################################
177# Check if server already exists
178#######################################
179check_existing_server() {
180 if hcloud server describe "$SERVER_NAME" &> /dev/null; then
181 log_warn "Server '$SERVER_NAME' already exists"
182 read -rp "Delete and recreate? [y/N] " confirm
183 if [[ "$confirm" =~ ^[Yy]$ ]]; then
184 log_info "Deleting existing server..."
185 hcloud server delete "$SERVER_NAME"
186 sleep 5
187 else
188 die "Aborted. Use teardown.sh to remove the existing server."
189 fi
190 fi
191}
192
193#######################################
194# Generate cloud-init config
195#######################################
196generate_cloud_init() {
197 log_info "Generating cloud-init config..."
198
199 local template="$SCRIPT_DIR/cloud-init.yaml.tmpl"
200 local output="$scratch/cloud-init.yaml"
201
202 if [[ ! -f "$template" ]]; then
203 die "cloud-init template not found: $template"
204 fi
205
206 # Substitute variables
207 sed \
208 -e "s|__TS_OAUTH_SECRET__|${TS_OAUTH_SECRET}|g" \
209 -e "s|__TS_TAG__|${TS_TAG}|g" \
210 -e "s|__GH_REPO__|${GH_REPO}|g" \
211 "$template" > "$output"
212
213 CLOUD_INIT_FILE="$output"
214 log_success "Cloud-init config generated"
215}
216
217#######################################
218# Create server
219#######################################
220create_server() {
221 log_info "Creating server..."
222
223 hcloud server create \
224 --name "$SERVER_NAME" \
225 --type "$HETZNER_SERVER_TYPE" \
226 --image debian-13 \
227 --location "$HETZNER_LOCATION" \
228 --ssh-key "$SSH_KEY_NAME" \
229 --user-data-from-file "$CLOUD_INIT_FILE"
230
231 # Get server IPs
232 SERVER_IP=$(hcloud server ip "$SERVER_NAME")
233 # hcloud returns a /64 prefix (e.g., 2a01:4f8::/64), convert to usable address
234 local ip6_raw
235 ip6_raw=$(hcloud server ip -6 "$SERVER_NAME" | head -1)
236 if [[ -n "$ip6_raw" && "$ip6_raw" =~ ^([^/]+):: ]]; then
237 SERVER_IP6="${BASH_REMATCH[1]}::1"
238 else
239 SERVER_IP6=""
240 fi
241 log_success "Server created: $SERVER_IP${SERVER_IP6:+ / $SERVER_IP6}"
242}
243
244#######################################
245# Update Cloudflare DNS
246#######################################
247update_dns_record() {
248 local record_type="$1"
249 local fqdn="$2"
250 local content="$3"
251 local api_base="https://api.cloudflare.com/client/v4"
252
253 # Check if record exists
254 local existing
255 existing=$(curl -s -X GET \
256 "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records?type=$record_type&name=$fqdn" \
257 -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
258 -H "Content-Type: application/json")
259
260 local record_id
261 record_id=$(echo "$existing" | jq -r '.result[0].id // empty')
262
263 if [[ -n "$record_id" ]]; then
264 # Update existing record
265 curl -s -X PUT \
266 "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records/$record_id" \
267 -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
268 -H "Content-Type: application/json" \
269 --data "{\"type\":\"$record_type\",\"name\":\"$fqdn\",\"content\":\"$content\",\"ttl\":300,\"proxied\":false}" \
270 | jq -e '.success' > /dev/null
271 log_success "DNS $record_type updated: $fqdn -> $content"
272 else
273 # Create new record
274 curl -s -X POST \
275 "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records" \
276 -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
277 -H "Content-Type: application/json" \
278 --data "{\"type\":\"$record_type\",\"name\":\"$fqdn\",\"content\":\"$content\",\"ttl\":300,\"proxied\":false}" \
279 | jq -e '.success' > /dev/null
280 log_success "DNS $record_type created: $fqdn -> $content"
281 fi
282}
283
284update_dns() {
285 log_info "Updating Cloudflare DNS..."
286
287 local fqdn="${SUBDOMAIN}.${DOMAIN}"
288
289 # Create/update A record (IPv4)
290 update_dns_record "A" "$fqdn" "$SERVER_IP"
291
292 # Create/update AAAA record (IPv6)
293 if [[ -n "$SERVER_IP6" ]]; then
294 update_dns_record "AAAA" "$fqdn" "$SERVER_IP6"
295 fi
296}
297
298#######################################
299# Wait for server to be ready
300#######################################
301wait_for_server() {
302 log_info "Waiting for server to be ready..."
303
304 local max_attempts=60
305 local attempt=0
306
307 # Wait for SSH
308 while [[ $attempt -lt $max_attempts ]]; do
309 if ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes \
310 "root@$SERVER_IP" "echo ok" &> /dev/null; then
311 break
312 fi
313 attempt=$((attempt + 1))
314 echo -n "."
315 sleep 5
316 done
317 echo
318
319 if [[ $attempt -ge $max_attempts ]]; then
320 die "Timeout waiting for SSH"
321 fi
322
323 # Capture host key for future connections
324 ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
325
326 log_success "SSH is available"
327
328 # Wait for cloud-init to complete, streaming log output in real-time
329 log_info "Waiting for cloud-init to complete (streaming log)..."
330
331 # Start tail -f in background
332 ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \
333 "tail -f /var/log/cloud-init-output.log 2>/dev/null" &
334 local tail_pid=$!
335
336 # Poll for completion file
337 attempt=0
338 while [[ $attempt -lt $max_attempts ]]; do
339 if ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \
340 "test -f /opt/.cloud-init-complete" &> /dev/null; then
341 break
342 fi
343 attempt=$((attempt + 1))
344 sleep 5
345 done
346
347 # Stop the tail process
348 kill "$tail_pid" 2>/dev/null || true
349 wait "$tail_pid" 2>/dev/null || true
350
351 if [[ $attempt -ge $max_attempts ]]; then
352 die "Timeout waiting for cloud-init"
353 fi
354
355 log_success "Cloud-init completed"
356}
357
358#######################################
359# Get Tailscale IP
360#######################################
361get_tailscale_ip() {
362 log_info "Getting Tailscale IP..."
363
364 TAILSCALE_IP=$(ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \
365 "tailscale ip -4" 2>/dev/null || echo "")
366
367 if [[ -z "$TAILSCALE_IP" ]]; then
368 log_warn "Could not get Tailscale IP. Check Tailscale auth."
369 else
370 log_success "Tailscale IP: $TAILSCALE_IP"
371 fi
372}
373
374#######################################
375# Generate and upload .env file
376#######################################
377upload_env_file() {
378 log_info "Generating .env file..."
379
380 local env_file="$scratch/.env"
381 local fqdn="${SUBDOMAIN}.${DOMAIN}"
382
383 cat > "$env_file" << EOF
384# Generated by deploy.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
385
386# === Server ===
387PORT=3000
388NODE_ENV=production
389
390# === Letta ===
391LETTA_BASE_URL=http://letta:8283
392LETTA_SERVER_PASSWORD=${LETTA_SERVER_PASSWORD}
393
394# === Telegram ===
395TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
396TELEGRAM_WEBHOOK_URL=https://${fqdn}/webhook
397TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN}
398
399# === Anthropic Proxy ===
400ANTHROPIC_PROXY_URL=http://anthropic-proxy:4001/v1
401ANTHROPIC_PROXY_SESSION_SECRET=${ANTHROPIC_PROXY_SESSION_SECRET}
402ANTHROPIC_PROXY_SESSION_ID=
403
404# === LiteLLM ===
405LITELLM_URL=http://litellm:4000
406
407# === OpenAI ===
408OPENAI_API_KEY=${OPENAI_API_KEY}
409
410# === Database ===
411DB_PATH=/app/data/assistant.db
412
413# === Tool Webhooks ===
414TOOL_WEBHOOK_URL=http://app:3000
415
416# === Monitoring ===
417NETDATA_CLAIM_TOKEN=${NETDATA_CLAIM_TOKEN:-}
418EOF
419
420 log_info "Uploading .env file..."
421 scp -o StrictHostKeyChecking=no "$env_file" "root@$SERVER_IP:/opt/assistant/.env"
422 log_success ".env file uploaded"
423}
424
425#######################################
426# Generate and upload Caddyfile
427#######################################
428upload_caddyfile() {
429 log_info "Generating Caddyfile..."
430
431 local caddyfile="$scratch/Caddyfile"
432 local fqdn="${SUBDOMAIN}.${DOMAIN}"
433
434 cat > "$caddyfile" << EOF
435# Generated by deploy.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
436
437# Main app - Telegram webhook
438${fqdn} {
439 reverse_proxy app:3000
440}
441
442# Netdata and Letta are accessed via Tailscale only
443# http://TAILSCALE_IP:19999 - Netdata
444# http://TAILSCALE_IP:8283 - Letta
445# Anthropic Proxy: internal only, use SSH tunnel for OAuth setup
446EOF
447
448 log_info "Uploading Caddyfile..."
449 scp -o StrictHostKeyChecking=no "$caddyfile" "root@$SERVER_IP:/opt/assistant/Caddyfile"
450 log_success "Caddyfile uploaded"
451}
452
453#######################################
454# Upload system prompt
455#######################################
456upload_system_prompt() {
457 log_info "Uploading system prompt..."
458
459 local prompt_file="$SCRIPT_DIR/../prompts/SYSTEM_PROMPT.md"
460
461 if [[ ! -f "$prompt_file" ]]; then
462 log_warn "System prompt not found: $prompt_file"
463 log_warn "Create it from the example: cp prompts/SYSTEM_PROMPT.md.example prompts/SYSTEM_PROMPT.md"
464 return
465 fi
466
467 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" "mkdir -p /opt/assistant/prompts"
468 scp -o StrictHostKeyChecking=no "$prompt_file" "root@$SERVER_IP:/opt/assistant/prompts/SYSTEM_PROMPT.md"
469 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" "chown -R 1000:1000 /opt/assistant/prompts"
470 log_success "System prompt uploaded"
471}
472
473#######################################
474# Start services
475#######################################
476start_services() {
477 log_info "Pulling pre-built images..."
478
479 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" << 'EOF'
480cd /opt/assistant
481docker compose -f docker-compose.yml -f docker-compose.prod.yml pull --ignore-buildable
482EOF
483
484 log_success "Images pulled"
485 log_info "Building custom images..."
486
487 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" << 'EOF'
488cd /opt/assistant
489export DOCKER_BUILDKIT=1
490export COMPOSE_DOCKER_CLI_BUILD=1
491docker compose -f docker-compose.yml -f docker-compose.prod.yml build
492EOF
493
494 log_success "Images built"
495 log_info "Starting services..."
496
497 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" << 'EOF'
498cd /opt/assistant
499docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
500EOF
501
502 log_success "Services started"
503}
504
505#######################################
506# Wait for health check
507#######################################
508wait_for_health() {
509 log_info "Waiting for services to be healthy..."
510
511 local fqdn="${SUBDOMAIN}.${DOMAIN}"
512 local max_attempts=30
513 local attempt=0
514
515 while [[ $attempt -lt $max_attempts ]]; do
516 if curl -sf "https://${fqdn}/health" &> /dev/null; then
517 break
518 fi
519 attempt=$((attempt + 1))
520 # Show container health status
521 local status
522 status=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 "root@$SERVER_IP" \
523 "cd /opt/assistant && docker compose -f docker-compose.yml -f docker-compose.prod.yml ps --format 'table {{.Service}}\t{{.Status}}' 2>/dev/null | tail -n +2 | tr '\n' ' '" 2>/dev/null || echo "connecting...")
524 printf "\r %-100s" "$status"
525 sleep 10
526 done
527 printf "\r%-110s\n" "" # Clear the status line
528
529 if [[ $attempt -ge $max_attempts ]]; then
530 log_warn "Health check timeout. Services may still be starting."
531 log_info "Check logs with: ssh root@$SERVER_IP 'docker compose -f /opt/assistant/docker-compose.yml -f /opt/assistant/docker-compose.prod.yml logs -f'"
532 else
533 log_success "Services healthy"
534 fi
535}
536
537#######################################
538# Set Telegram webhook
539#######################################
540set_telegram_webhook() {
541 log_info "Setting Telegram webhook..."
542
543 local fqdn="${SUBDOMAIN}.${DOMAIN}"
544 local webhook_url="https://${fqdn}/webhook"
545
546 local response
547 response=$(curl -s -X POST \
548 "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \
549 -H "Content-Type: application/json" \
550 -d "{\"url\":\"$webhook_url\",\"secret_token\":\"$TELEGRAM_WEBHOOK_SECRET_TOKEN\"}")
551
552 if echo "$response" | jq -e '.ok' > /dev/null; then
553 log_success "Telegram webhook set: $webhook_url"
554 else
555 log_warn "Failed to set webhook: $response"
556 fi
557}
558
559#######################################
560# Get deploy key for GitHub Actions
561#######################################
562get_deploy_key() {
563 log_info "Getting deploy key for GitHub Actions..."
564
565 DEPLOY_KEY_PUBLIC=$(ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \
566 "cat /root/.ssh/deploy_key.pub" 2>/dev/null || echo "")
567
568 # Write private key to file with restricted permissions (never print to stdout)
569 DEPLOY_KEY_FILE="$SCRIPT_DIR/deploy_key_${SERVER_NAME}"
570 ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \
571 "cat /root/.ssh/deploy_key" 2>/dev/null > "$DEPLOY_KEY_FILE" || true
572
573 if [[ -s "$DEPLOY_KEY_FILE" ]]; then
574 chmod 600 "$DEPLOY_KEY_FILE"
575 log_success "Deploy key saved to: $DEPLOY_KEY_FILE"
576 else
577 rm -f "$DEPLOY_KEY_FILE"
578 DEPLOY_KEY_FILE=""
579 log_warn "Could not retrieve deploy key"
580 fi
581}
582
583#######################################
584# Print summary
585#######################################
586print_summary() {
587 local fqdn="${SUBDOMAIN}.${DOMAIN}"
588
589 echo
590 echo -e "${GREEN}========================================${NC}"
591 echo -e "${GREEN} Deployment Complete!${NC}"
592 echo -e "${GREEN}========================================${NC}"
593 echo
594 echo -e "${BLUE}Server:${NC}"
595 echo " IPv4: $SERVER_IP"
596 echo " IPv6: ${SERVER_IP6:-'(none)'}"
597 echo " Tailscale: ${TAILSCALE_IP:-'(check Tailscale admin)'}"
598 echo
599 echo -e "${BLUE}Service URLs:${NC}"
600 echo " App (public): https://${fqdn}"
601 echo " Health check: https://${fqdn}/health"
602 if [[ -n "${TAILSCALE_IP:-}" ]]; then
603 echo " Letta (Tailscale): http://${TAILSCALE_IP}:8283"
604 echo " Netdata (Tailscale): http://${TAILSCALE_IP}:19999"
605 fi
606 echo " OAuth setup (SSH tunnel): ssh -L 4001:localhost:4001 root@$SERVER_IP"
607 echo
608 echo -e "${YELLOW}NEXT STEP: Complete Anthropic OAuth${NC}"
609 echo " The OAuth endpoint is internal-only. Use SSH tunnel to access it:"
610 echo
611 echo " ssh -L 4001:localhost:4001 root@$SERVER_IP"
612 echo " # Then open in browser: http://localhost:4001/auth/device"
613 echo
614 echo " After completing OAuth, copy the session ID and update .env:"
615 echo " ssh root@$SERVER_IP"
616 echo " nano /opt/assistant/.env"
617 echo " # Set ANTHROPIC_PROXY_SESSION_ID=your_session_id"
618 echo " docker compose -f docker-compose.yml -f docker-compose.prod.yml restart"
619 echo
620 if [[ -n "${DEPLOY_KEY_PUBLIC:-}" ]]; then
621 echo -e "${BLUE}GitHub Actions Setup:${NC}"
622 echo " Add this deploy key to GitHub (Settings → Deploy keys):"
623 echo " $DEPLOY_KEY_PUBLIC"
624 echo
625 echo " Add these secrets to GitHub Actions (Settings → Secrets):"
626 echo " HOST: $SERVER_IP"
627 if [[ -n "${DEPLOY_KEY_FILE:-}" ]]; then
628 echo " SSH_KEY: contents of $DEPLOY_KEY_FILE"
629 fi
630 echo
631 fi
632 echo -e "${BLUE}Useful commands:${NC}"
633 echo " SSH: ssh root@$SERVER_IP"
634 echo " Logs: ssh root@$SERVER_IP 'cd /opt/assistant && docker compose logs -f'"
635 echo " Restart: ssh root@$SERVER_IP 'cd /opt/assistant && docker compose -f docker-compose.yml -f docker-compose.prod.yml restart'"
636 echo " Teardown: ./teardown.sh"
637 echo
638}
639
640#######################################
641# Main
642#######################################
643main() {
644 echo -e "${BLUE}========================================${NC}"
645 echo -e "${BLUE} Assistant Deployment Script${NC}"
646 echo -e "${BLUE}========================================${NC}"
647 echo
648
649 check_prerequisites
650 load_secrets
651 setup_ssh_key
652 check_existing_server
653 generate_cloud_init
654 create_server
655 update_dns
656 wait_for_server
657 get_tailscale_ip
658 upload_env_file
659 upload_caddyfile
660 upload_system_prompt
661 start_services
662 wait_for_health
663 set_telegram_webhook
664 get_deploy_key
665 print_summary
666}
667
668main "$@"