spacehub

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

claude-code / examples / gateway / gcp / terraform / main.tf
399 lines · 15.7 KBRaw
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
5locals {
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 ───────────────────────────────────────────────────
25resource "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) ───────────────────────────────
35resource "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.
51resource "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.
59resource "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 ──────────────────────────────────────────
69resource "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
76resource "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
84resource "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
93resource "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) ───────────────────────────────────────────
107resource "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
125resource "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")
133resource "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")
139resource "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.
152resource "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")
162resource "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")
169resource "random_password" "jwt" {
170 length = 48
171 special = false
172}
173
174resource "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")
184resource "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).
190resource "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")
200resource "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.
211check "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).
219resource "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")
229resource "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
245resource "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
252resource "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
259resource "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
266resource "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) ─────────────────────────────────────────
274resource "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).
392resource "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}