surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
| 1 | # Claude Gateway on Cloud Run — Terraform equivalent of setup.sh. |
| 2 | # Section markers (§N) map to setup.sh and the walkthrough: |
| 3 | # https://code.claude.com/docs/en/claude-apps-gateway-on-gcp |
| 4 | |
| 5 | locals { |
| 6 | config_path = var.gateway_config_path != "" ? var.gateway_config_path : "${path.module}/../gateway.yaml" |
| 7 | gateway_config = file(local.config_path) |
| 8 | image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.ar_repo}/${var.image_name}:${var.image_tag}" |
| 9 | |
| 10 | apis = [ |
| 11 | "aiplatform.googleapis.com", |
| 12 | "artifactregistry.googleapis.com", |
| 13 | "cloudresourcemanager.googleapis.com", |
| 14 | "sqladmin.googleapis.com", |
| 15 | "secretmanager.googleapis.com", |
| 16 | "iamcredentials.googleapis.com", |
| 17 | "iam.googleapis.com", |
| 18 | "compute.googleapis.com", |
| 19 | "servicenetworking.googleapis.com", |
| 20 | "run.googleapis.com", |
| 21 | ] |
| 22 | } |
| 23 | |
| 24 | # ── 1 Project & API setup ─────────────────────────────────────────────────── |
| 25 | resource "google_project_service" "apis" { |
| 26 | for_each = toset(local.apis) |
| 27 | project = var.project_id |
| 28 | service = each.value |
| 29 | # Don't disable APIs (or delete anything) when this config is torn down. |
| 30 | disable_on_destroy = false |
| 31 | disable_dependent_services = false |
| 32 | } |
| 33 | |
| 34 | # ── 2 Service account & IAM (least-privilege) ─────────────────────────────── |
| 35 | resource "google_service_account" "gateway" { |
| 36 | project = var.project_id |
| 37 | account_id = var.sa_name |
| 38 | display_name = "Claude Gateway" |
| 39 | depends_on = [google_project_service.apis] |
| 40 | } |
| 41 | |
| 42 | # Non-authoritative (_member) so we never clobber other project bindings. |
| 43 | # |
| 44 | # Only aiplatform.user is granted: the gateway reaches Cloud SQL over the VPC at |
| 45 | # its private IP with a password user (direct TCP via Direct VPC egress — see §7 |
| 46 | # below), not via the Cloud SQL Auth Proxy / connector, so it never calls |
| 47 | # cloudsql.instances.connect and no roles/cloudsql.client grant is needed. |
| 48 | # Direct private-IP keeps the gateway's store a plain postgres_url with no proxy |
| 49 | # sidecar/socket plumbing, and the connection string is portable across Cloud |
| 50 | # Run and GKE. |
| 51 | resource "google_project_iam_member" "vertex" { |
| 52 | project = var.project_id |
| 53 | role = "roles/aiplatform.user" # Agent Platform inference |
| 54 | member = "serviceAccount:${google_service_account.gateway.email}" |
| 55 | } |
| 56 | |
| 57 | # ── 3 Artifact Registry repo ──────────────────────────────────────────────── |
| 58 | # NOTE: image build/push is a separate step (see README) — Terraform only makes the repo. |
| 59 | resource "google_artifact_registry_repository" "repo" { |
| 60 | project = var.project_id |
| 61 | location = var.region |
| 62 | repository_id = var.ar_repo |
| 63 | format = "DOCKER" |
| 64 | description = "Claude Gateway container images" |
| 65 | depends_on = [google_project_service.apis] |
| 66 | } |
| 67 | |
| 68 | # ── 4 VPC + Private Services Access ────────────────────────────────────────── |
| 69 | resource "google_compute_network" "vpc" { |
| 70 | project = var.project_id |
| 71 | name = var.vpc_network |
| 72 | auto_create_subnetworks = false |
| 73 | depends_on = [google_project_service.apis] |
| 74 | } |
| 75 | |
| 76 | resource "google_compute_subnetwork" "subnet" { |
| 77 | project = var.project_id |
| 78 | name = var.subnet |
| 79 | region = var.region |
| 80 | network = google_compute_network.vpc.id |
| 81 | ip_cidr_range = var.subnet_range |
| 82 | } |
| 83 | |
| 84 | resource "google_compute_global_address" "psa_range" { |
| 85 | project = var.project_id |
| 86 | name = "google-managed-services-${var.vpc_network}" |
| 87 | purpose = "VPC_PEERING" |
| 88 | address_type = "INTERNAL" |
| 89 | prefix_length = var.psa_prefix_length |
| 90 | network = google_compute_network.vpc.id |
| 91 | } |
| 92 | |
| 93 | resource "google_service_networking_connection" "psa" { |
| 94 | network = google_compute_network.vpc.id |
| 95 | service = "servicenetworking.googleapis.com" |
| 96 | reserved_peering_ranges = [google_compute_global_address.psa_range.name] |
| 97 | # ABANDON: on destroy, leave the producer peering in place (deleting it can hang |
| 98 | # and would affect any other private-IP service on this VPC). |
| 99 | deletion_policy = "ABANDON" |
| 100 | # If the peering already exists (e.g. a previous apply failed partway), patch it |
| 101 | # instead of failing the create. |
| 102 | update_on_creation_fail = true |
| 103 | depends_on = [google_project_service.apis] |
| 104 | } |
| 105 | |
| 106 | # ── 4 Cloud SQL (private IP only) ─────────────────────────────────────────── |
| 107 | resource "google_sql_database_instance" "db" { |
| 108 | project = var.project_id |
| 109 | name = var.db_instance |
| 110 | region = var.region |
| 111 | database_version = var.db_version |
| 112 | deletion_protection = var.deletion_protection |
| 113 | depends_on = [google_service_networking_connection.psa] |
| 114 | |
| 115 | settings { |
| 116 | tier = var.db_tier |
| 117 | ip_configuration { |
| 118 | ipv4_enabled = false # private IP only (org policy: sql.restrictPublicIp) |
| 119 | private_network = google_compute_network.vpc.id |
| 120 | ssl_mode = "ENCRYPTED_ONLY" |
| 121 | } |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | resource "google_sql_database" "db" { |
| 126 | project = var.project_id |
| 127 | name = var.db_name |
| 128 | instance = google_sql_database_instance.db.name |
| 129 | } |
| 130 | |
| 131 | # URL-safe (alphanumeric) so it drops cleanly into the connection string. |
| 132 | # nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state") |
| 133 | resource "random_password" "db" { |
| 134 | length = 32 |
| 135 | special = false |
| 136 | } |
| 137 | |
| 138 | # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state") |
| 139 | resource "google_sql_user" "gateway" { |
| 140 | project = var.project_id |
| 141 | name = var.db_user |
| 142 | instance = google_sql_database_instance.db.name |
| 143 | password = random_password.db.result |
| 144 | # On destroy the role owns the tables it migrated at boot, so DROP ROLE can |
| 145 | # fail (and races google_sql_database.db). ABANDON is harmless on the |
| 146 | # greenfield teardown — the whole instance is deleted anyway. |
| 147 | deletion_policy = "ABANDON" |
| 148 | } |
| 149 | |
| 150 | # ── 5/6 Secrets + secretAccessor ──────────────────────────────────────────── |
| 151 | # postgres-url: connection string built from the instance's private IP. |
| 152 | resource "google_secret_manager_secret" "postgres_url" { |
| 153 | project = var.project_id |
| 154 | secret_id = var.secret_name |
| 155 | replication { |
| 156 | auto {} |
| 157 | } |
| 158 | depends_on = [google_project_service.apis] |
| 159 | } |
| 160 | |
| 161 | # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state") |
| 162 | resource "google_secret_manager_secret_version" "postgres_url" { |
| 163 | secret = google_secret_manager_secret.postgres_url.id |
| 164 | secret_data = "postgres://${var.db_user}:${random_password.db.result}@${google_sql_database_instance.db.private_ip_address}:5432/${var.db_name}?sslmode=require" |
| 165 | } |
| 166 | |
| 167 | # jwt: session signing key. |
| 168 | # nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state") |
| 169 | resource "random_password" "jwt" { |
| 170 | length = 48 |
| 171 | special = false |
| 172 | } |
| 173 | |
| 174 | resource "google_secret_manager_secret" "jwt" { |
| 175 | project = var.project_id |
| 176 | secret_id = var.jwt_secret_name |
| 177 | replication { |
| 178 | auto {} |
| 179 | } |
| 180 | depends_on = [google_project_service.apis] |
| 181 | } |
| 182 | |
| 183 | # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state") |
| 184 | resource "google_secret_manager_secret_version" "jwt" { |
| 185 | secret = google_secret_manager_secret.jwt.id |
| 186 | secret_data = random_password.jwt.result |
| 187 | } |
| 188 | |
| 189 | # oidc client secret: operator-provided (from the Google OAuth client). |
| 190 | resource "google_secret_manager_secret" "oidc" { |
| 191 | project = var.project_id |
| 192 | secret_id = var.oidc_secret_name |
| 193 | replication { |
| 194 | auto {} |
| 195 | } |
| 196 | depends_on = [google_project_service.apis] |
| 197 | } |
| 198 | |
| 199 | # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state") |
| 200 | resource "google_secret_manager_secret_version" "oidc" { |
| 201 | count = var.oidc_client_secret != "" ? 1 : 0 |
| 202 | secret = google_secret_manager_secret.oidc.id |
| 203 | secret_data = var.oidc_client_secret |
| 204 | } |
| 205 | |
| 206 | # Warn (not block) at plan time when the OIDC secret value isn't set: the Cloud |
| 207 | # Run service mounts gateway-oidc-client-secret:latest unconditionally, so an |
| 208 | # empty value with no out-of-band version means the apply fails late at |
| 209 | # revision creation. A warning (not a precondition) keeps the documented |
| 210 | # out-of-band-version mode usable. |
| 211 | check "oidc_client_secret_set" { |
| 212 | assert { |
| 213 | condition = var.oidc_client_secret != "" |
| 214 | error_message = "oidc_client_secret is empty — set it in terraform.tfvars, or add a version to the gateway-oidc-client-secret secret out-of-band before applying (the Cloud Run revision mounts it at :latest and will fail without one)." |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | # config: gateway.yaml. Guard mirrors the bash REPLACE_ME check (non-comment lines). |
| 219 | resource "google_secret_manager_secret" "config" { |
| 220 | project = var.project_id |
| 221 | secret_id = var.config_secret_name |
| 222 | replication { |
| 223 | auto {} |
| 224 | } |
| 225 | depends_on = [google_project_service.apis] |
| 226 | } |
| 227 | |
| 228 | # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state") |
| 229 | resource "google_secret_manager_secret_version" "config" { |
| 230 | secret = google_secret_manager_secret.config.id |
| 231 | secret_data = local.gateway_config |
| 232 | |
| 233 | lifecycle { |
| 234 | precondition { |
| 235 | condition = length([ |
| 236 | for line in split("\n", local.gateway_config) : |
| 237 | line |
| 238 | if !startswith(trimspace(line), "#") && strcontains(line, "REPLACE_ME") |
| 239 | ]) == 0 |
| 240 | error_message = "gateway.yaml still has REPLACE_ME on a non-comment line — fill it in before applying." |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | resource "google_secret_manager_secret_iam_member" "postgres_url" { |
| 246 | project = var.project_id |
| 247 | secret_id = google_secret_manager_secret.postgres_url.secret_id |
| 248 | role = "roles/secretmanager.secretAccessor" |
| 249 | member = "serviceAccount:${google_service_account.gateway.email}" |
| 250 | } |
| 251 | |
| 252 | resource "google_secret_manager_secret_iam_member" "jwt" { |
| 253 | project = var.project_id |
| 254 | secret_id = google_secret_manager_secret.jwt.secret_id |
| 255 | role = "roles/secretmanager.secretAccessor" |
| 256 | member = "serviceAccount:${google_service_account.gateway.email}" |
| 257 | } |
| 258 | |
| 259 | resource "google_secret_manager_secret_iam_member" "oidc" { |
| 260 | project = var.project_id |
| 261 | secret_id = google_secret_manager_secret.oidc.secret_id |
| 262 | role = "roles/secretmanager.secretAccessor" |
| 263 | member = "serviceAccount:${google_service_account.gateway.email}" |
| 264 | } |
| 265 | |
| 266 | resource "google_secret_manager_secret_iam_member" "config" { |
| 267 | project = var.project_id |
| 268 | secret_id = google_secret_manager_secret.config.secret_id |
| 269 | role = "roles/secretmanager.secretAccessor" |
| 270 | member = "serviceAccount:${google_service_account.gateway.email}" |
| 271 | } |
| 272 | |
| 273 | # ── 7 Cloud Run (Direct VPC egress) ───────────────────────────────────────── |
| 274 | resource "google_cloud_run_v2_service" "gateway" { |
| 275 | project = var.project_id |
| 276 | name = var.service_name |
| 277 | location = var.region |
| 278 | ingress = var.ingress |
| 279 | invoker_iam_disabled = var.invoker_iam_disabled |
| 280 | deletion_protection = var.deletion_protection |
| 281 | |
| 282 | template { |
| 283 | service_account = google_service_account.gateway.email |
| 284 | scaling { |
| 285 | min_instance_count = var.min_instances |
| 286 | max_instance_count = var.max_instances |
| 287 | } |
| 288 | # Secrets are mounted at version=latest, so a config edit or secret |
| 289 | # rotation alone wouldn't diff this resource and the warm min_instances=1 |
| 290 | # revision would keep the old values. Stamping a hash of the rendered |
| 291 | # config + every managed secret value forces a new revision whenever any |
| 292 | # of them change — without this, tainting random_password.db ALTERs the |
| 293 | # SQL role to the new password while the running revision keeps the old |
| 294 | # connection string and breaks on its next reconnect, and rotating the |
| 295 | # OIDC client secret leaves login failing invalid_client. |
| 296 | labels = { |
| 297 | config-sha = substr(sha256(join("", [ |
| 298 | local.gateway_config, |
| 299 | random_password.db.result, |
| 300 | random_password.jwt.result, |
| 301 | var.oidc_client_secret, |
| 302 | ])), 0, 63) |
| 303 | } |
| 304 | # Cloud Run's default 300s request timeout would cut off long streaming |
| 305 | # /v1/messages responses mid-stream. |
| 306 | timeout = "3600s" |
| 307 | |
| 308 | vpc_access { |
| 309 | network_interfaces { |
| 310 | network = google_compute_network.vpc.id |
| 311 | subnetwork = google_compute_subnetwork.subnet.id |
| 312 | } |
| 313 | egress = "PRIVATE_RANGES_ONLY" # public egress (Agent Platform, accounts.google.com) bypasses the VPC -> no Cloud NAT needed |
| 314 | } |
| 315 | |
| 316 | containers { |
| 317 | image = local.image |
| 318 | ports { container_port = 8080 } |
| 319 | |
| 320 | # gateway.yaml mounted as a file at /etc/claude/gateway.yaml (alone in its dir). |
| 321 | volume_mounts { |
| 322 | name = "config" |
| 323 | mount_path = "/etc/claude" |
| 324 | } |
| 325 | |
| 326 | # Cloud Run can't mount multiple secrets in one dir, so the rest are env vars |
| 327 | # (gateway.yaml references them via ${ENV_VAR}). |
| 328 | env { |
| 329 | name = "GATEWAY_JWT_SECRET" |
| 330 | value_source { |
| 331 | secret_key_ref { |
| 332 | secret = google_secret_manager_secret.jwt.secret_id |
| 333 | version = "latest" |
| 334 | } |
| 335 | } |
| 336 | } |
| 337 | env { |
| 338 | name = "OIDC_CLIENT_SECRET" |
| 339 | value_source { |
| 340 | secret_key_ref { |
| 341 | secret = google_secret_manager_secret.oidc.secret_id |
| 342 | version = "latest" |
| 343 | } |
| 344 | } |
| 345 | } |
| 346 | env { |
| 347 | name = "GATEWAY_POSTGRES_URL" |
| 348 | value_source { |
| 349 | secret_key_ref { |
| 350 | secret = google_secret_manager_secret.postgres_url.secret_id |
| 351 | version = "latest" |
| 352 | } |
| 353 | } |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | volumes { |
| 358 | name = "config" |
| 359 | secret { |
| 360 | secret = google_secret_manager_secret.config.secret_id |
| 361 | items { |
| 362 | path = "gateway.yaml" |
| 363 | version = "latest" |
| 364 | } |
| 365 | } |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | depends_on = [ |
| 370 | google_secret_manager_secret_iam_member.config, |
| 371 | google_secret_manager_secret_iam_member.jwt, |
| 372 | google_secret_manager_secret_iam_member.oidc, |
| 373 | google_secret_manager_secret_iam_member.postgres_url, |
| 374 | google_secret_manager_secret_version.config, |
| 375 | google_secret_manager_secret_version.postgres_url, |
| 376 | google_secret_manager_secret_version.jwt, |
| 377 | google_secret_manager_secret_version.oidc, |
| 378 | google_sql_database.db, |
| 379 | google_sql_user.gateway, |
| 380 | google_project_service.apis, |
| 381 | ] |
| 382 | } |
| 383 | |
| 384 | # Public access at the Cloud Run IAM layer — the gateway runs its own OIDC, so the |
| 385 | # invoker check must be opened or disabled (real auth stays the gateway's SSO): |
| 386 | # Preferred — disable it: invoker_iam_disabled=true on the service above. No allUsers |
| 387 | # binding at all, and it works under Domain Restricted Sharing. |
| 388 | # Fallback — open it: this allUsers run.invoker grant. Domain Restricted Sharing orgs |
| 389 | # reject allUsers, and an LB does NOT bypass that (ingress is network-layer; the IAM |
| 390 | # check still runs) — use invoker_iam_disabled, a DRS exception, or GKE. |
| 391 | # Skipped when invoker_iam_disabled=true (the grant would be redundant, and DRS rejects it). |
| 392 | resource "google_cloud_run_v2_service_iam_member" "public" { |
| 393 | count = var.allow_unauthenticated && !var.invoker_iam_disabled ? 1 : 0 |
| 394 | project = var.project_id |
| 395 | location = var.region |
| 396 | name = google_cloud_run_v2_service.gateway.name |
| 397 | role = "roles/run.invoker" |
| 398 | member = "allUsers" |
| 399 | } |