diff --git a/README.md b/README.md index c3c40c2..2cb1187 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OpenClaw on DigitalOcean + Azure +# OpenClaw on DigitalOcean + Azure + GCP [![Security Checks](https://github.com/PCBZ/OpenClaw_Docker/actions/workflows/security.yml/badge.svg)](https://github.com/PCBZ/OpenClaw_Docker/actions/workflows/security.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) @@ -6,12 +6,13 @@ [![Terraform](https://img.shields.io/badge/Terraform-%3E%3D1.5-844fba?logo=terraform&logoColor=white)](https://www.terraform.io) [![DigitalOcean](https://img.shields.io/badge/DigitalOcean-Droplet-0080ff?logo=digitalocean&logoColor=white)](https://www.digitalocean.com) [![Azure](https://img.shields.io/badge/Azure-VM-0078d4?logo=microsoft-azure&logoColor=white)](https://azure.microsoft.com) +[![GCP](https://img.shields.io/badge/GCP-CloudRun-4285F4?logo=googlecloud&logoColor=white)](https://cloud.google.com/run) [![OpenRouter](https://img.shields.io/badge/OpenRouter-Free%20Tier-ff6b35?logoColor=white)](https://openrouter.ai) [![OpenClaw](https://img.shields.io/badge/OpenClaw-2026-00e5cc?logoColor=white)](https://openclaw.bot) [![Telegram](https://img.shields.io/badge/Telegram-Bot-26a5e4?logo=telegram&logoColor=white)](https://telegram.org) [![Slack](https://img.shields.io/badge/Slack-Bot-4a154b?logo=slack&logoColor=white)](https://slack.com) -One-command deployment of an [OpenClaw](https://openclaw.bot) AI agent on DigitalOcean or Azure VM with Telegram and Slack support. After `terraform apply`, the bot is fully operational with no manual SSH steps required. +One-command deployment of an [OpenClaw](https://openclaw.bot) AI agent on DigitalOcean, Azure VM, or GCP Cloud Run with Telegram and Slack support. After `terraform apply`, the bot is fully operational with no manual SSH steps required. ## Features @@ -28,6 +29,7 @@ One-command deployment of an [OpenClaw](https://openclaw.bot) AI agent on Digita - SSH key pair - DigitalOcean account + API token (for DO path) - Azure subscription + service principal credentials (for Azure path) +- GCP project with Cloud Run + Storage + Secret Manager enabled (for Cloud Run path) - OpenRouter API key - Telegram bot token (from [@BotFather](https://t.me/BotFather)) - Slack App-Level token (starts with `xapp-`) @@ -96,6 +98,40 @@ swap_size = 2 openclaw_memory_limit_mb = 800 ``` +#### Option C: GCP Cloud Run + +```bash +cd terraform/gcp_cloudrun +cp terraform.tfvars.example terraform.tfvars +``` + +Edit `terraform.tfvars`: + +```hcl +project_id = "your-gcp-project-id" +region = "us-west1" +service_name = "openclaw" + +# Leave empty to use Artifact Registry GHCR proxy +container_image = "" +enable_ghcr_proxy = true +ghcr_remote_repository_id = "ghcr-remote" +ghcr_image_path = "openclaw/openclaw" +ghcr_image_tag = "latest" + +bucket_name = "your-gcp-project-id-openclaw-state" +min_instances = 1 +max_instances = 1 +``` + +Cloud Run deployment in this repo uses: +- `2 vCPU`, `4Gi` memory +- CPU always allocated (`cpu_idle = false`) +- `min_instances = 1` +- Runtime state on local ephemeral storage (`/tmp/openclaw-state`) for startup stability +- Config persistence via mounted GCS bucket (`openclaw.json`, mounted read-only) +- `bonjour` plugin disabled for Cloud Run compatibility (avoids mDNS probing crash loop) + ### 3. Load secrets via direnv ```bash @@ -116,10 +152,19 @@ Wait ~5 minutes for bootstrap to complete. The bot will start automatically. ### 5. Verify ```bash -terraform output ssh_command # SSH into the server if needed +terraform output cloud_run_url ``` -Send a message to your bot on Telegram to confirm it's working. +Then: +- Open the Cloud Run URL to confirm service is reachable. +- Send a Telegram message to confirm bot response. +- (Optional) send a Slack message if Slack tokens are configured. + +## Cloud Run Notes + +- This Cloud Run setup intentionally does **not** persist full runtime/plugin cache state. +- Only required static/config artifacts are persisted in GCS. +- If you destroy infra but want to keep APIs enabled, this repo sets `disable_on_destroy = false` for required GCP services. ## Switching Models diff --git a/terraform/gcp_cloudrun/.envrc b/terraform/gcp_cloudrun/.envrc new file mode 100644 index 0000000..578de8b --- /dev/null +++ b/terraform/gcp_cloudrun/.envrc @@ -0,0 +1,21 @@ +# Auto-load secrets from .env into Terraform variables +# Requires: brew install direnv && eval "$(direnv hook zsh)" >> ~/.zshrc + +dotenv ../../.env + +export TF_VAR_project_id="${GCP_PROJECT_ID:-}" +export TF_VAR_gcp_credentials_json="${GCP_CREDENTIALS_JSON:-}" +if [ -n "${GCP_CREDENTIALS_FILE:-}" ]; then + credentials_file_expanded="${GCP_CREDENTIALS_FILE/#\~/$HOME}" + if [ -f "${credentials_file_expanded}" ]; then + export TF_VAR_gcp_credentials_json="$(cat "${credentials_file_expanded}")" + fi +fi + +export TF_VAR_openrouter_api_key="$OPENROUTER_API_KEY" +export TF_VAR_telegram_bot_token="$TELEGRAM_BOT_TOKEN" +export TF_VAR_openclaw_gateway_token="$OPENCLAW_GATEWAY_TOKEN" +export TF_VAR_brave_api_key="${BRAVE_API_KEY:-}" +export TF_VAR_telegram_owner_id="${TELEGRAM_OWNER_ID:-}" +export TF_VAR_slack_app_token="${SLACK_APP_TOKEN:-}" +export TF_VAR_slack_bot_token="${SLACK_BOT_TOKEN:-}" diff --git a/terraform/gcp_cloudrun/identity_iam.tf b/terraform/gcp_cloudrun/identity_iam.tf new file mode 100644 index 0000000..4b8ee31 --- /dev/null +++ b/terraform/gcp_cloudrun/identity_iam.tf @@ -0,0 +1,18 @@ +resource "google_service_account" "cloudrun" { + account_id = "${var.service_name}-run-sa" + display_name = "OpenClaw Cloud Run runtime" + + depends_on = [google_project_service.required] +} + +resource "google_storage_bucket_iam_member" "state_rw" { + bucket = google_storage_bucket.state.name + role = "roles/storage.objectViewer" + member = "serviceAccount:${google_service_account.cloudrun.email}" +} + +resource "google_project_iam_member" "secret_accessor" { + project = var.project_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.cloudrun.email}" +} diff --git a/terraform/gcp_cloudrun/main.tf b/terraform/gcp_cloudrun/main.tf new file mode 100644 index 0000000..eaf2356 --- /dev/null +++ b/terraform/gcp_cloudrun/main.tf @@ -0,0 +1,154 @@ +resource "google_cloud_run_v2_service" "openclaw" { + name = var.service_name + location = var.region + ingress = "INGRESS_TRAFFIC_ALL" + deletion_protection = false + + template { + service_account = google_service_account.cloudrun.email + timeout = "3600s" + + scaling { + min_instance_count = var.min_instances + max_instance_count = var.max_instances + } + + containers { + image = local.effective_container_image + command = ["/bin/sh"] + args = ["-lc", "openclaw gateway run --bind lan --port \"$${PORT:-8080}\" --allow-unconfigured"] + + ports { + container_port = 8080 + } + + # First boot may stage plugin runtime deps on the mounted GCS volume. + # Give startup probe enough budget to avoid false negatives. + startup_probe { + initial_delay_seconds = 5 + period_seconds = 10 + timeout_seconds = 5 + failure_threshold = 60 + tcp_socket { + port = 8080 + } + } + + resources { + limits = { + cpu = "2" + memory = "4Gi" + } + cpu_idle = false + } + + volume_mounts { + name = "openclaw-state" + mount_path = "/mnt/openclaw-persist" + } + + env { + name = "HOME" + value = "/home/node" + } + + env { + name = "OPENCLAW_STATE_DIR" + value = "/tmp/openclaw-state" + } + + env { + name = "OPENCLAW_CONFIG_PATH" + value = "/mnt/openclaw-persist/openclaw.json" + } + + env { + name = "OPENCLAW_CONFIG_HASH" + value = md5(local.openclaw_json_content) + } + + env { + name = "OPENCLAW_GATEWAY_PORT" + value = "8080" + } + + env { + name = "OPENROUTER_API_KEY" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.openrouter_api_key.secret_id + version = "latest" + } + } + } + + env { + name = "TELEGRAM_BOT_TOKEN" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.telegram_bot_token.secret_id + version = "latest" + } + } + } + + env { + name = "OPENCLAW_GATEWAY_TOKEN" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.gateway_token.secret_id + version = "latest" + } + } + } + + dynamic "env" { + for_each = var.brave_api_key != "" ? [1] : [] + content { + name = "BRAVE_API_KEY" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.brave_api_key[0].secret_id + version = "latest" + } + } + } + } + + env { + name = "SLACK_APP_TOKEN" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.slack_app_token.secret_id + version = "latest" + } + } + } + + env { + name = "SLACK_BOT_TOKEN" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.slack_bot_token.secret_id + version = "latest" + } + } + } + } + + volumes { + name = "openclaw-state" + gcs { + bucket = google_storage_bucket.state.name + read_only = true + } + } + } + + depends_on = [ + google_artifact_registry_repository_iam_member.ghcr_remote_reader, + google_storage_bucket_object.openclaw_json, + google_storage_bucket_iam_member.state_rw, + google_project_iam_member.secret_accessor + ] +} diff --git a/terraform/gcp_cloudrun/openclaw.json.tpl b/terraform/gcp_cloudrun/openclaw.json.tpl new file mode 100644 index 0000000..362382f --- /dev/null +++ b/terraform/gcp_cloudrun/openclaw.json.tpl @@ -0,0 +1,80 @@ +{ + "gateway": { + "bind": "lan", + "auth": { "mode": "token", "token": "${openclaw_gateway_token}" }, + "mode": "local", + "remote": { "token": "${openclaw_gateway_token}" } + }, + "agents": { + "defaults": { + "model": { + "primary": "openrouter/openai/gpt-4o-mini", + "fallbacks": [ + "openrouter/anthropic/claude-haiku-4.5", + "openrouter/meta-llama/llama-3.3-70b-instruct:free", + "openrouter/auto" + ] + }, + "models": { + "openrouter/anthropic/claude-opus-4.6": {"alias": "opus"}, + "openrouter/anthropic/claude-sonnet-4.6": {"alias": "sonnet"}, + "openrouter/anthropic/claude-haiku-4.5": {"alias": "haiku"}, + "openrouter/openai/gpt-5.4": {"alias": "gpt5"}, + "openrouter/openai/gpt-4o": {"alias": "gpt4o"}, + "openrouter/openai/gpt-4o-mini": {"alias": "mini"}, + "openrouter/google/gemini-2.5-pro": {"alias": "gemini-pro"}, + "openrouter/google/gemini-2.5-flash": {"alias": "flash"}, + "openrouter/deepseek/deepseek-r1": {"alias": "r1"}, + "openrouter/mistralai/devstral-small": {"alias": "devstral"}, + "openrouter/meta-llama/llama-3.3-70b-instruct:free": {"alias": "llama"}, + "openrouter/nvidia/nemotron-3-super-120b-a12b:free": {"alias": "nemotron"}, + "openrouter/qwen/qwen3-coder:free": {"alias": "coder"}, + "openrouter/cognitivecomputations/dolphin-mistral-24b-venice-edition:free": {"alias": "uncensored"}, + "openrouter/auto": {"alias": "auto"} + }, + "compaction": { "mode": "safeguard", "reserveTokensFloor": 4000 } + } + }, + "tools": { + "web": { + "search": { "enabled": true, "provider": "brave" }, + "fetch": { "enabled": true } + }, + "deny": ["browser"] + }, + "plugins": { + "entries": { + "bonjour": { "enabled": false }, + "telegram": { "enabled": true }, + "slack": { "enabled": true }, + "openrouter": { "enabled": true }, + "brave": { + "enabled": true, + "config": { "webSearch": { "apiKey": "${brave_api_key}" } } + } + } + }, + "channels": { + "telegram": { + "enabled": true, + "allowFrom": ["*"], + "accounts": { + "default": { + "botToken": "${telegram_bot_token}", + "allowFrom": ["*"], + "dmPolicy": "open", + "groupPolicy": "open" + } + } + }, + "slack": { + "enabled": true, + "mode": "socket", + "allowFrom": ["*"], + "appToken": "${slack_app_token}", + "botToken": "${slack_bot_token}", + "dmPolicy": "open", + "groupPolicy": "open" + } + } +} diff --git a/terraform/gcp_cloudrun/outputs.tf b/terraform/gcp_cloudrun/outputs.tf new file mode 100644 index 0000000..6918649 --- /dev/null +++ b/terraform/gcp_cloudrun/outputs.tf @@ -0,0 +1,19 @@ +output "cloud_run_service_name" { + description = "Cloud Run service name" + value = google_cloud_run_v2_service.openclaw.name +} + +output "cloud_run_url" { + description = "Cloud Run service URL" + value = google_cloud_run_v2_service.openclaw.uri +} + +output "state_bucket_name" { + description = "Persistent OpenClaw state bucket" + value = google_storage_bucket.state.name +} + +output "runtime_service_account" { + description = "Runtime service account email" + value = google_service_account.cloudrun.email +} diff --git a/terraform/gcp_cloudrun/platform.tf b/terraform/gcp_cloudrun/platform.tf new file mode 100644 index 0000000..9ce0b9b --- /dev/null +++ b/terraform/gcp_cloudrun/platform.tf @@ -0,0 +1,56 @@ +locals { + effective_container_image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.ghcr_remote_repository_id}/${var.ghcr_image_path}:${var.ghcr_image_tag}" + + openclaw_json_content = templatefile("${path.module}/openclaw.json.tpl", { + openclaw_gateway_token = var.openclaw_gateway_token + openrouter_api_key = var.openrouter_api_key + brave_api_key = var.brave_api_key + telegram_bot_token = var.telegram_bot_token + slack_app_token = var.slack_app_token + slack_bot_token = var.slack_bot_token + }) +} + +resource "google_project_service" "required" { + for_each = toset([ + "run.googleapis.com", + "artifactregistry.googleapis.com", + "storage.googleapis.com", + "secretmanager.googleapis.com", + "iam.googleapis.com", + "serviceusage.googleapis.com" + ]) + + project = var.project_id + service = each.key + disable_on_destroy = false + disable_dependent_services = false +} + +resource "google_artifact_registry_repository" "ghcr_remote" { + project = var.project_id + location = var.region + repository_id = var.ghcr_remote_repository_id + description = var.ghcr_remote_repository_description + format = "DOCKER" + mode = "REMOTE_REPOSITORY" + + remote_repository_config { + description = "GitHub Container Registry proxy" + docker_repository { + custom_repository { + uri = var.ghcr_upstream_uri + } + } + } + + depends_on = [google_project_service.required] +} + +resource "google_artifact_registry_repository_iam_member" "ghcr_remote_reader" { + project = var.project_id + location = var.region + repository = google_artifact_registry_repository.ghcr_remote.repository_id + role = "roles/artifactregistry.reader" + member = "serviceAccount:${google_service_account.cloudrun.email}" +} diff --git a/terraform/gcp_cloudrun/provider.tf b/terraform/gcp_cloudrun/provider.tf new file mode 100644 index 0000000..1f730fe --- /dev/null +++ b/terraform/gcp_cloudrun/provider.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.0" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 6.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region + credentials = var.gcp_credentials_json != "" ? var.gcp_credentials_json : null +} diff --git a/terraform/gcp_cloudrun/secrets.tf b/terraform/gcp_cloudrun/secrets.tf new file mode 100644 index 0000000..5cca845 --- /dev/null +++ b/terraform/gcp_cloudrun/secrets.tf @@ -0,0 +1,73 @@ +resource "google_secret_manager_secret" "openrouter_api_key" { + secret_id = "${var.service_name}-openrouter-api-key" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "openrouter_api_key" { + secret = google_secret_manager_secret.openrouter_api_key.id + secret_data = var.openrouter_api_key +} + +resource "google_secret_manager_secret" "telegram_bot_token" { + secret_id = "${var.service_name}-telegram-bot-token" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "telegram_bot_token" { + secret = google_secret_manager_secret.telegram_bot_token.id + secret_data = var.telegram_bot_token +} + +resource "google_secret_manager_secret" "gateway_token" { + secret_id = "${var.service_name}-gateway-token" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "gateway_token" { + secret = google_secret_manager_secret.gateway_token.id + secret_data = var.openclaw_gateway_token +} + +resource "google_secret_manager_secret" "brave_api_key" { + count = var.brave_api_key != "" ? 1 : 0 + secret_id = "${var.service_name}-brave-api-key" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "brave_api_key" { + count = var.brave_api_key != "" ? 1 : 0 + secret = google_secret_manager_secret.brave_api_key[0].id + secret_data = var.brave_api_key +} + +resource "google_secret_manager_secret" "slack_app_token" { + secret_id = "${var.service_name}-slack-app-token" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "slack_app_token" { + secret = google_secret_manager_secret.slack_app_token.id + secret_data = var.slack_app_token +} + +resource "google_secret_manager_secret" "slack_bot_token" { + secret_id = "${var.service_name}-slack-bot-token" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "slack_bot_token" { + secret = google_secret_manager_secret.slack_bot_token.id + secret_data = var.slack_bot_token +} diff --git a/terraform/gcp_cloudrun/storage.tf b/terraform/gcp_cloudrun/storage.tf new file mode 100644 index 0000000..4fe1fa8 --- /dev/null +++ b/terraform/gcp_cloudrun/storage.tf @@ -0,0 +1,20 @@ +resource "google_storage_bucket" "state" { + name = var.bucket_name + location = var.region + uniform_bucket_level_access = true + force_destroy = true + + depends_on = [google_project_service.required] +} + +resource "google_storage_bucket_object" "openclaw_json" { + name = "openclaw.json" + bucket = google_storage_bucket.state.name + content = local.openclaw_json_content +} + +resource "google_storage_bucket_object" "workspace_keep" { + name = "workspace/.keep" + bucket = google_storage_bucket.state.name + content = "keep" +} diff --git a/terraform/gcp_cloudrun/terraform.tfvars.example b/terraform/gcp_cloudrun/terraform.tfvars.example new file mode 100644 index 0000000..2e85fe3 --- /dev/null +++ b/terraform/gcp_cloudrun/terraform.tfvars.example @@ -0,0 +1,21 @@ +# Copy to terraform.tfvars and fill in your values +# cp terraform.tfvars.example terraform.tfvars + +# ── GCP Core ──────────────────────────────────────────────── +project_id = "your-gcp-project-id" +region = "us-west1" + +# Optional: use when you do not rely on ADC +# gcp_credentials_json = file("path/to/service-account.json") + +# ── Cloud Run ──────────────────────────────────────────────── +service_name = "openclaw" +min_instances = 1 +max_instances = 3 + +ghcr_remote_repository_id = "ghcr-remote" +ghcr_image_path = "openclaw/openclaw" +ghcr_image_tag = "latest" + +# ── Persistent State ───────────────────────────────────────── +bucket_name = "your-gcp-project-id-openclaw-state" diff --git a/terraform/gcp_cloudrun/variables.tf b/terraform/gcp_cloudrun/variables.tf new file mode 100644 index 0000000..c1b6f48 --- /dev/null +++ b/terraform/gcp_cloudrun/variables.tf @@ -0,0 +1,103 @@ +variable "project_id" { + description = "GCP project ID" + type = string +} + +variable "region" { + description = "GCP region (Cloud Run)" + type = string + default = "us-west1" +} + +variable "service_name" { + description = "Cloud Run service name" + type = string + default = "openclaw" +} + +variable "ghcr_remote_repository_id" { + description = "Artifact Registry repository_id for GHCR proxy." + type = string + default = "ghcr-remote" +} + +variable "ghcr_remote_repository_description" { + description = "Description for GHCR proxy remote repository." + type = string + default = "Proxy cache for ghcr.io images" +} + +variable "ghcr_upstream_uri" { + description = "Upstream URI for GHCR remote repository." + type = string + default = "https://ghcr.io" +} + +variable "ghcr_image_path" { + description = "Image path on GHCR to pull through the proxy." + type = string + default = "openclaw/openclaw" +} + +variable "ghcr_image_tag" { + description = "Image tag on GHCR to pull through the proxy." + type = string + default = "latest" +} + +variable "gcp_credentials_json" { + description = "Optional service account JSON content. Leave empty to use ADC." + type = string + sensitive = true + default = "" +} + +variable "bucket_name" { + description = "GCS bucket name for persistent OpenClaw state" + type = string +} + +variable "min_instances" { + description = "Cloud Run minimum instances" + type = number + default = 1 +} + +variable "max_instances" { + description = "Cloud Run maximum instances" + type = number + default = 3 +} + +variable "openrouter_api_key" { + sensitive = true +} + +variable "telegram_bot_token" { + sensitive = true +} + +variable "openclaw_gateway_token" { + sensitive = true +} + +variable "brave_api_key" { + description = "Brave Search API key (optional)" + sensitive = true + default = "" +} + +variable "telegram_owner_id" { + description = "Telegram numeric user ID for privileged commands" + default = "" +} + +variable "slack_app_token" { + description = "Slack App-Level Token (xapp-...)" + sensitive = true +} + +variable "slack_bot_token" { + description = "Slack Bot OAuth Token (xoxb-...)" + sensitive = true +}