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

Add Claude Gateway on GCP example deployment assetsBrowse files
Reference artifacts accompanying the Gateway-on-GCP walkthrough
(https://code.claude.com/docs/en/claude-apps-gateway-on-gcp), including
its Terraform reference section:

- setup.sh: scripts the walkthrough end to end via gcloud (APIs, service
  account, image build/push, private-IP Cloud SQL, secrets, Cloud Run)
- Dockerfile: runtime image for the gateway; setup.sh downloads the
  public release binary and verifies it against the release manifest
- gateway.yaml.example: config template (Vertex upstream, Google
  Workspace IdP)
- terraform/: module provisioning the same architecture (Cloud Run track)

Provided as a working example to adapt, not a supported production
deployment.
Roy Arsan authored on Jun 29, 2026 parent 01f1617 · commit 6234fa8f143e
13 changed files+1626 -0compared with 01f1617
Aexamples/gateway/gcp/.dockerignore
Aexamples/gateway/gcp/.gitignore
Aexamples/gateway/gcp/Dockerfile
Aexamples/gateway/gcp/README.md
Aexamples/gateway/gcp/gateway.yaml.example
Aexamples/gateway/gcp/setup.sh
Aexamples/gateway/gcp/terraform/.gitignore
Aexamples/gateway/gcp/terraform/README.md
Aexamples/gateway/gcp/terraform/main.tf
Aexamples/gateway/gcp/terraform/outputs.tf
Aexamples/gateway/gcp/terraform/terraform.tfvars.example
Aexamples/gateway/gcp/terraform/variables.tf
Aexamples/gateway/gcp/terraform/versions.tf
@@ -1,0 +1,13 @@
1+# Keep the build context to just the binary the Dockerfile COPYs. BuildKit (the
2+# default, selected via the Dockerfile's syntax directive) only syncs the
3+# referenced COPY source anyway, so this is a no-op there — it matters for the
4+# classic builder (DOCKER_BUILDKIT=0) and as a conventional signal that the
5+# .gitignore'd secrets in this directory aren't part of the image build.
6+terraform/
7+**/.terraform/
8+*.tfstate*
9+terraform.tfvars
10+gateway.yaml
11+secrets/
12+*.pem
13+client_secret_*.json
@@ -1,0 +1,12 @@
1+# Local, environment-specific config — copy gateway.yaml.example -> gateway.yaml
2+# (gateway.yaml.example IS committed; your filled-in gateway.yaml is not)
3+gateway.yaml
4+
5+# Secrets / credentials — never commit
6+secrets/
7+client_secret_*.json
8+*.pem
9+
10+# Release binary and pinned version — setup.sh downloads/writes these per release
11+claude
12+.claude-version
@@ -1,0 +1,35 @@
1+# syntax=docker/dockerfile:1
2+
3+# Runtime image for `claude gateway`.
4+#
5+# This image does NOT build the binary. It expects a prebuilt native
6+# linux-x64 `claude` executable in the build context — the public Claude Code
7+# release binary, which includes the `gateway` subcommand. setup.sh places it
8+# at ./claude (downloading it from the public release endpoint and verifying
9+# it against the release manifest if missing). Override CLAUDE_BINARY to
10+# point at a different path.
11+#
12+# Build (with the binary at ./claude; otherwise add --build-arg CLAUDE_BINARY=<path>):
13+# docker build --platform=linux/amd64 --provenance=false -t claude-gateway .
14+#
15+# Run:
16+# docker run --rm -p 8080:8080 \
17+# -v "$PWD/gateway.yaml:/etc/claude/gateway.yaml:ro" \
18+# -e OIDC_CLIENT_SECRET -e GATEWAY_JWT_SECRET -e GATEWAY_POSTGRES_URL \
19+# claude-gateway
20+
21+ARG CLAUDE_BINARY=./claude
22+
23+# distroless/cc provides glibc + libstdc++ (required by the Bun-compiled
24+# native binary). The :nonroot tag runs as uid/gid 65532.
25+FROM gcr.io/distroless/cc-debian12:nonroot
26+
27+ARG CLAUDE_BINARY
28+COPY --chmod=0755 ${CLAUDE_BINARY} /usr/local/bin/claude
29+
30+ENV CLAUDE_CONFIG_DIR=/tmp/.claude
31+
32+EXPOSE 8080
33+USER nonroot
34+
35+ENTRYPOINT ["/usr/local/bin/claude", "gateway", "--config", "/etc/claude/gateway.yaml"]
@@ -1,0 +1,18 @@
1+# Claude Gateway on Google Cloud
2+
3+Reference deployment artifacts for running Claude Gateway on GCP with Vertex AI
4+as the upstream: Cloud Run or GKE, Cloud SQL for PostgreSQL, Secret Manager, and
5+service-account auth to Vertex AI.
6+
7+These files are provided as a working example rather than a supported production
8+deployment. Adapt them to your own environment.
9+
10+- **Walkthrough**: https://code.claude.com/docs/en/claude-apps-gateway-on-gcp
11+- **Public mirror**: https://github.com/anthropics/claude-code/tree/main/examples/gateway/gcp
12+
13+| File | Purpose |
14+|---|---|
15+| `setup.sh` | Scripts the walkthrough end to end via `gcloud` |
16+| `Dockerfile` | Runtime image for the `claude gateway` binary |
17+| `gateway.yaml.example` | Gateway config template, GCP-shaped (Vertex upstream, Google Workspace IdP) |
18+| `terraform/` | Provisions the full architecture (two-pass apply — see `terraform/README.md`) |
@@ -1,0 +1,155 @@
1+# gateway.yaml.example — Claude Gateway config template, GCP-shaped (walkthrough §6).
2+#
3+# Google Workspace IdP + Vertex upstream, following the walkthrough at
4+# https://code.claude.com/docs/en/claude-apps-gateway-on-gcp. The active sections
5+# below are a strict subset of the full configuration reference at
6+# https://code.claude.com/docs/en/claude-apps-gateway; optional keys are included
7+# commented-out.
8+#
9+# USAGE — this is the shippable TEMPLATE. Copy it to gateway.yaml and fill it in:
10+# cp gateway.yaml.example gateway.yaml
11+# setup.sh and terraform/ read gateway.yaml (your filled-in copy, which is
12+# gitignored). It is published as the Secret Manager secret `gateway-config`
13+# (§6) and mounted at /etc/claude/gateway.yaml — the container ENTRYPOINT runs
14+# `claude gateway --config /etc/claude/gateway.yaml`.
15+#
16+# Secret expansion: ${ENV_VAR} reads an env var; ${file:/path} reads a mounted file.
17+# On Cloud Run, setup.sh injects the JWT / OIDC / Postgres secrets as ENV VARS
18+# (Cloud Run can't mount multiple secrets into a single directory), and mounts
19+# only gateway.yaml itself as a file at /etc/claude. On GKE you may use file mounts.
20+#
21+# BEFORE DEPLOY — replace every REPLACE_ME placeholder below (setup.sh refuses to
22+# publish the config secret while any remain), and create the referenced secrets:
23+# gateway-jwt-secret (setup.sh generates this)
24+# gateway-oidc-client-secret (from the Google Cloud Console OAuth client)
25+# gateway-postgres-url (setup.sh generates this)
26+
27+# ── Listener ─────────────────────────────────────────────────────────────────
28+listen:
29+ host: 0.0.0.0
30+ port: 8080 # Cloud Run sets PORT=8080; leave as-is
31+ # Required. Fixes the IdP redirect_uri, the OIDC discovery doc, and the
32+ # gateway-token issuer so none are derived from the client-controlled Host
33+ # header (X-Forwarded-Host/-Proto are likewise never trusted). On Cloud Run
34+ # the run.app URL is only assigned on the first deploy, so this starts as a
35+ # placeholder for the provisioning-only first pass (login does NOT work until
36+ # the real URL is set). After the first deploy, setup.sh prints the run.app
37+ # URL: set it here (or your LB hostname) and re-run; setup.sh republishes the
38+ # config and redeploys. Register the same host's /oauth/callback on the
39+ # Google OAuth client.
40+ public_url: https://set-after-first-deploy.invalid
41+ # Register this exact redirect URI on the Google OAuth client:
42+ # https://<public_url host>/oauth/callback
43+ #
44+ # On Cloud Run (or behind any L7 LB) every request arrives via Google's front
45+ # end, so the gateway sees one peer IP for all developers — set trusted_proxies
46+ # so X-Forwarded-For from those proxies is trusted and per-IP rate limiting /
47+ # audit IPs record the real client. 169.254.0.0/16 is Cloud Run's fixed
48+ # link-local serving range; the proxy-only subnet is the one your internal ALB
49+ # uses in this VPC.
50+ # trusted_proxies:
51+ # - 169.254.0.0/16 # Cloud Run serving proxy (link-local peer)
52+ # - <proxy-only-subnet-cidr> # add if fronted by your internal ALB (its proxy-only subnet)
53+ #
54+ # Alternative — terminate TLS in the gateway itself instead of at a proxy:
55+ # tls:
56+ # cert: /certs/gateway.crt
57+ # key: /certs/gateway.key
58+
59+# ── Identity provider — Google Workspace ─────────────────────────────────────
60+oidc:
61+ issuer: https://accounts.google.com
62+ client_id: REPLACE_ME # Google OAuth client ID (not secret; from Cloud Console)
63+ client_secret: ${OIDC_CLIENT_SECRET}
64+ allowed_email_domains: [REPLACE_ME] # e.g. [example.com] — reject id_tokens outside your org
65+ # Google ignores the default offline_access scope; these two are what actually
66+ # yield refresh tokens (silent renewal + the deprovision leash) from Google.
67+ scopes: [openid, profile, email]
68+ extra_auth_params: { access_type: offline, prompt: consent }
69+ # NOTE: Google id_tokens carry NO groups claim. For group-based RBAC with
70+ # Google as IdP, set `google_groups` (below) and the gateway fetches each
71+ # user's Workspace groups at login via the Admin SDK Directory API.
72+ # Otherwise, use email_domain matching (see managed.policies below).
73+ # google_groups:
74+ # service_account_json_path: /secrets/google-sa.json # SA with domain-wide delegation on admin.directory.group.readonly
75+ # admin_email: admin@example.com # a Workspace admin the SA impersonates
76+ # groups_claim: groups # Okta=groups, Entra app roles=roles — NOT Google
77+ # ca_cert_pem: ${file:/secrets/idp-ca.pem} # only for an IdP behind a private CA
78+
79+# ── Sessions ─────────────────────────────────────────────────────────────────
80+session:
81+ jwt_secret: ${GATEWAY_JWT_SECRET} # >= 32 bytes; openssl rand -base64 32
82+ # Google issues refresh tokens (above), so sessions renew silently and this
83+ # mainly bounds deprovision latency. 8 is a sane default; lower toward 1 for
84+ # tighter revocation. Array form rotates keys: [new, old] (index 0 signs, all verify).
85+ ttl_hours: 8
86+
87+# ── Store (REQUIRED — the gateway refuses to boot without it) ─────────────────
88+store:
89+ postgres_url: ${GATEWAY_POSTGRES_URL} # private-IP Cloud SQL; built with ?sslmode=require by setup.sh
90+
91+# ── Upstreams — Vertex AI ────────────────────────────────────────────────────
92+upstreams:
93+ - provider: vertex
94+ region: us-east5 # a region where the Claude models you need are published in Model Garden
95+ project_id: REPLACE_ME # your GCP project ID for Vertex access
96+ auth: {} # ADC via Cloud Run SA / GKE Workload Identity (preferred — no static keys)
97+ # base_url: https://us-east5-aiplatform.p.googleapis.com # Private Service Connect endpoint
98+ # Add more upstreams for failover (tried top→bottom on 5xx/timeout/501): a
99+ # second region, or an anthropic/bedrock fallback. See
100+ # https://code.claude.com/docs/en/claude-apps-gateway.
101+
102+# ── Telemetry fan-out (OPTIONAL) ─────────────────────────────────────────────
103+# The CLI sends OTLP/HTTP to the gateway; the gateway fans out, stamping
104+# user.id/user.email/user.groups server-side. On GCP, point at an OpenTelemetry
105+# Collector with the googlecloud exporter (-> Cloud Trace / Managed Prometheus).
106+# Takes effect after the second pass (once public_url is the real URL, not the
107+# placeholder): when forward_to and public_url are both configured the gateway pushes
108+# CLAUDE_CODE_ENABLE_TELEMETRY and the OTEL exporter selectors to every client
109+# automatically — no per-developer config needed.
110+# telemetry:
111+# forward_to:
112+# - url: https://otel-collector.internal.example.com:4318
113+# headers:
114+# Authorization: ${file:/secrets/otlp-token}
115+# metrics: true # safe aggregate counters (default)
116+# logs: false # carries bash commands / tool inputs — opt in deliberately
117+# traces: false
118+
119+# ── RBAC + managed settings (OPTIONAL; first-match-wins, top -> bottom) ───────
120+# With Google as IdP, match on email_domain, or on group email addresses
121+# (e.g. eng@example.com) once oidc.google_groups is configured above.
122+# managed:
123+# policies:
124+# - match: { email_domain: example.com }
125+# cli:
126+# availableModels: [claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5]
127+# permissions: { deny: ["Read(./.env)", "Read(./secrets/**)"] }
128+# - match: {} # catch-all floor — keep LAST
129+# cli:
130+# availableModels: [claude-sonnet-4-6, claude-haiku-4-5]
131+
132+# ── Admin API (OPTIONAL — enables db-mode runtime config + spend caps) ───────
133+# admin_groups needs a groups claim — with Google as IdP, set
134+# oidc.google_groups (above) so Workspace group email addresses populate the
135+# claim, or use the bootstrap keys below instead. Named keys for
136+# attribution in the audit log; 32-char minimum on key values. On Cloud Run add
137+# these as env vars to --set-secrets (or terraform env value_source blocks),
138+# same as the JWT/OIDC/Postgres secrets above; on GKE you may use ${file:...}.
139+# admin:
140+# write_keys:
141+# - id: terraform
142+# key: ${GATEWAY_ADMIN_WRITE_KEY}
143+# read_keys:
144+# - id: reporting
145+# key: ${GATEWAY_ADMIN_READ_KEY}
146+# # admin_groups: [platform-finops@example.com] # group emails via oidc.google_groups, or any groups-capable IdP
147+
148+# ── Model catalog (OPTIONAL) ─────────────────────────────────────────────────
149+# Default true: every built-in Claude model is exposed and auto-translated per
150+# upstream. Set false + a models: list to pin IDs (e.g. provisioned throughput).
151+# auto_include_builtin_models: true
152+# models:
153+# - id: claude-opus-4-8
154+# label: Claude Opus 4.8
155+# upstream_model: { vertex: claude-opus-4-8 }
@@ -1,0 +1,558 @@
1+#!/usr/bin/env bash
2+#
3+# setup.sh — GCP setup for Claude Gateway (walkthrough §1–7b).
4+#
5+# Provisions, in doc order: APIs (§1), service account + IAM (§2), the gateway
6+# container image in Artifact Registry (§3), a Cloud SQL (PostgreSQL) backend
7+# with PRIVATE IP only (§4), the JWT + postgres-url secrets (§5), the
8+# gateway.yaml config secret (§6), and a Cloud Run deploy with Direct VPC
9+# egress (§7b).
10+#
11+# Private IP is required because public IP is disallowed by the org-policy constraint
12+# `constraints/sql.restrictPublicIp`. A Cloud SQL private IP is an address inside a VPC,
13+# so §4 here also provisions the prerequisite VPC + Private Services Access — the
14+# one-time, irreducible networking required for private IP.
15+#
16+# Section markers (§N) below map to the walkthrough:
17+# https://code.claude.com/docs/en/claude-apps-gateway-on-gcp
18+#
19+# Covers here: APIs (§1) -> service account + IAM (§2) -> build & push image (§3)
20+# -> VPC + Private Services Access -> Cloud SQL (private IP only) -> database
21+# + user (§4) -> jwt + postgres-url secrets (§5) -> gateway-config
22+# secret from gateway.yaml (§6) -> Cloud Run deploy (§7b).
23+# Not covered: GKE track (§7a) — Cloud Run is the lower-friction path here.
24+#
25+# Idempotent: existing resources are detected and skipped, so it is safe to re-run.
26+# Override any default below via environment variable, e.g. `REGION=us-east5 ./setup.sh`.
27+
28+set -euo pipefail
29+
30+# ---- configuration (env-overridable) ----------------------------------------
31+PROJECT_ID="${PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}"
32+REGION="${REGION:-${CLOUDSDK_COMPUTE_REGION:-us-east5}}" # guide §1 uses us-east5 (Vertex model region)
33+
34+SA_NAME="${SA_NAME:-claude-gateway}" # §2 service account
35+SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
36+
37+# §3 image
38+AR_REPO="${AR_REPO:-claude-gateway}" # Artifact Registry repository
39+IMAGE_NAME="${IMAGE_NAME:-gateway}"
40+RELEASES_URL="${RELEASES_URL:-https://downloads.claude.ai/claude-code-releases}" # public Claude Code release endpoint
41+VERSION="${VERSION:-}" # Claude Code release to deploy; empty = latest release (resolved below)
42+VERSION_FILE="${VERSION_FILE:-./.claude-version}" # pins the resolved release across re-runs; delete it (or set VERSION) to upgrade
43+DOCKERFILE="${DOCKERFILE:-./Dockerfile}"
44+CLAUDE_BINARY="${CLAUDE_BINARY:-./claude}" # linux-x64 Claude Code binary; downloaded from RELEASES_URL if missing
45+CLAUDE_SHA256="${CLAUDE_SHA256:-}" # optional: out-of-band sha256 pin for the downloaded binary, checked in addition to the release manifest
46+
47+VPC_NETWORK="${VPC_NETWORK:-cc-gateway-vpc}"
48+SUBNET="${SUBNET:-cc-gateway-subnet}"
49+SUBNET_RANGE="${SUBNET_RANGE:-10.0.0.0/24}"
50+
51+PSA_RANGE_NAME="${PSA_RANGE_NAME:-google-managed-services-${VPC_NETWORK}}"
52+PSA_PREFIX_LENGTH="${PSA_PREFIX_LENGTH:-16}" # /16 is GCP's recommendation; reserved, not consumed
53+
54+DB_INSTANCE="${DB_INSTANCE:-claude-gateway-db}"
55+DB_VERSION="${DB_VERSION:-POSTGRES_16}" # PG14+ supported; 16 is the recommended default (§4)
56+DB_TIER="${DB_TIER:-db-g1-small}"
57+DB_NAME="${DB_NAME:-claude_gateway}"
58+DB_USER="${DB_USER:-gateway}"
59+
60+SECRET_NAME="${SECRET_NAME:-gateway-postgres-url}" # §5 store.postgres_url
61+JWT_SECRET_NAME="${JWT_SECRET_NAME:-gateway-jwt-secret}" # §5 session.jwt_secret
62+
63+GATEWAY_YAML="${GATEWAY_YAML:-./gateway.yaml}" # §6 config file
64+CONFIG_SECRET="${CONFIG_SECRET:-gateway-config}" # §6 mounted at /etc/claude/gateway.yaml
65+
66+# §7 Cloud Run deploy
67+SERVICE_NAME="${SERVICE_NAME:-claude-gateway}"
68+OIDC_SECRET_NAME="${OIDC_SECRET_NAME:-gateway-oidc-client-secret}" # operator-created (Google OAuth client)
69+DEPLOY="${DEPLOY:-1}" # set DEPLOY=0 to provision only, no Cloud Run deploy
70+INGRESS="${INGRESS:-internal}" # internal (default; no public URL) | internal-and-cloud-load-balancing (only if you front it with your own internal ALB)
71+MAX_INSTANCES="${MAX_INSTANCES:-8}" # keep MAX_INSTANCES × store.max_connections (default 5) below the DB tier's max_connections (~50 on db-g1-small); raise the tier before raising this
72+
73+# ---- helpers ----------------------------------------------------------------
74+log() { printf '\n==> %s\n' "$*"; }
75+skip() { printf ' (exists) %s\n' "$*"; }
76+curl_https() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 "$@"; } # refuse plaintext/protocol-downgrade
77+sha_of() { openssl dgst -sha256 "$1" | awk '{print $NF}'; } # openssl avoids shasum/sha256sum portability gaps
78+
79+if [[ -z "${PROJECT_ID}" ]]; then
80+ echo "ERROR: PROJECT_ID is not set and no gcloud default project is configured." >&2
81+ echo " Set it with: export PROJECT_ID=<your-project> (or 'gcloud config set project ...')" >&2
82+ exit 1
83+fi
84+# VERSION tags the image and selects the public Claude Code release to download.
85+# The first resolved value is pinned to ${VERSION_FILE} so the documented
86+# re-runs (fill gateway.yaml -> re-run; set public_url -> re-run) don't silently
87+# build and deploy a newer release mid-bootstrap.
88+if [[ -z "${VERSION}" && -f "${VERSION_FILE}" ]]; then
89+ VERSION="$(< "${VERSION_FILE}")"
90+ log "Using release pinned in ${VERSION_FILE}: ${VERSION} (delete the file or set VERSION to change it)"
91+elif [[ -z "${VERSION}" ]]; then
92+ # /latest is the channel the official installer (claude.ai/install.sh) uses.
93+ VERSION="$(curl_https -fsSL "${RELEASES_URL}/latest" | tr -d '[:space:]' || true)"
94+ if [[ -z "${VERSION}" ]]; then
95+ echo "ERROR: could not resolve the latest release from ${RELEASES_URL}/latest." >&2
96+ echo " Set VERSION to a Claude Code release version, e.g. export VERSION=2.1.195" >&2
97+ exit 1
98+ fi
99+ log "VERSION not set — using latest Claude Code release: ${VERSION}"
100+fi
101+# Reject non-version content (e.g. an HTML error page served with HTTP 200)
102+# before it reaches the image tag and download URLs.
103+if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
104+ echo "ERROR: '${VERSION}' is not a release version (from VERSION, ${VERSION_FILE}, or ${RELEASES_URL}/latest)." >&2
105+ exit 1
106+fi
107+printf '%s' "${VERSION}" > "${VERSION_FILE}"
108+IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${AR_REPO}/${IMAGE_NAME}:${VERSION}"
109+# Claude Code only connects to a gateway whose hostname resolves to private
110+# addresses (a client-side /login check), so public ingress can never serve
111+# clients — mirror the terraform module's validation and refuse it up front.
112+if [[ "${INGRESS}" != "internal" && "${INGRESS}" != "internal-and-cloud-load-balancing" ]]; then
113+ echo "ERROR: INGRESS must be 'internal' or 'internal-and-cloud-load-balancing' — Claude Code's" >&2
114+ echo " /login only accepts gateway hosts on private addresses, so public ingress cannot serve clients." >&2
115+ exit 1
116+fi
117+
118+log "Project: ${PROJECT_ID} Region: ${REGION} VPC: ${VPC_NETWORK}"
119+
120+# ---- 1 Project & API setup ------------------------------------------------
121+# walkthrough §1 list (aiplatform, artifactregistry, sqladmin, secretmanager, iamcredentials)
122+# plus iam/compute/servicenetworking required for the SA + private-IP networking below.
123+# container.googleapis.com is for the GKE track (§7a) — harmless if you stay on Cloud Run.
124+# We pass --project on every call rather than mutating your gcloud config.
125+log "Enabling required APIs (§1)"
126+gcloud services enable \
127+ aiplatform.googleapis.com \
128+ artifactregistry.googleapis.com \
129+ sqladmin.googleapis.com \
130+ secretmanager.googleapis.com \
131+ iamcredentials.googleapis.com \
132+ iam.googleapis.com \
133+ compute.googleapis.com \
134+ container.googleapis.com \
135+ servicenetworking.googleapis.com \
136+ run.googleapis.com \
137+ --project="${PROJECT_ID}"
138+
139+# ---- 2 Service account & IAM ----------------------------------------------
140+log "Creating service account ${SA_EMAIL} and granting project roles (§2)"
141+if gcloud iam service-accounts describe "${SA_EMAIL}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
142+ skip "service account ${SA_EMAIL}"
143+else
144+ gcloud iam service-accounts create "${SA_NAME}" \
145+ --display-name="Claude Gateway" --project="${PROJECT_ID}"
146+fi
147+
148+# add-iam-policy-binding is idempotent (re-adding an existing binding is a no-op).
149+# --condition=None avoids the interactive condition prompt in non-interactive runs.
150+#
151+# Only aiplatform.user is granted: the gateway reaches Cloud SQL over the VPC at
152+# its PRIVATE IP with a password user (§4/§7b — direct TCP, not the Cloud SQL
153+# Auth Proxy / connector), so it never calls cloudsql.instances.connect and no
154+# roles/cloudsql.client grant is needed. Direct private-IP is used because the
155+# gateway's store is a plain postgres_url — no proxy sidecar/socket plumbing,
156+# one less moving part, and the connection string is portable across Cloud Run
157+# and GKE.
158+gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
159+ --member="serviceAccount:${SA_EMAIL}" \
160+ --role="roles/aiplatform.user" --condition=None >/dev/null # Vertex inference (§2)
161+
162+# ---- 3 Build & push image to Artifact Registry ----------------------------
163+log "Ensuring Artifact Registry repo and image (§3)"
164+if gcloud artifacts repositories describe "${AR_REPO}" \
165+ --location="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
166+ skip "Artifact Registry repo ${AR_REPO}"
167+else
168+ gcloud artifacts repositories create "${AR_REPO}" \
169+ --repository-format=docker --location="${REGION}" --project="${PROJECT_ID}"
170+fi
171+
172+# Image is the expensive, already-done step: skip the build+push entirely if the
173+# tag already exists in the registry.
174+if gcloud artifacts docker images describe "${IMAGE}" >/dev/null 2>&1; then
175+ skip "image ${IMAGE}"
176+else
177+ # The public Claude Code release includes the gateway subcommand, so the
178+ # binary comes straight from the release endpoint, verified against the
179+ # release manifest's sha256. A pre-existing ${CLAUDE_BINARY} (stale version,
180+ # interrupted download, hand-placed file) is verified the same way and
181+ # re-downloaded on mismatch, so an unverified binary can never reach the image.
182+ manifest="$(curl_https -fsSL "${RELEASES_URL}/${VERSION}/manifest.json" | tr -d '[:space:]' || true)"
183+ sha_re='"linux-x64"[^}]*"checksum":"([a-f0-9]{64})"' # structure-based: survives pretty-printed, minified, and one-line-per-platform manifests
184+ if [[ ! "${manifest}" =~ ${sha_re} ]]; then
185+ echo "ERROR: could not read the linux-x64 sha256 from ${RELEASES_URL}/${VERSION}/manifest.json — refusing to build." >&2
186+ exit 1
187+ fi
188+ expected_sha="${BASH_REMATCH[1]}"
189+ if [[ -f "${CLAUDE_BINARY}" && "$(sha_of "${CLAUDE_BINARY}")" == "${expected_sha}" ]]; then
190+ skip "binary ${CLAUDE_BINARY} (sha256 matches release ${VERSION})"
191+ else
192+ if [[ -f "${CLAUDE_BINARY}" ]]; then
193+ log "Existing ${CLAUDE_BINARY} does not match release ${VERSION} — re-downloading"
194+ else
195+ log "Downloading Claude Code ${VERSION} (linux-x64) from ${RELEASES_URL}"
196+ fi
197+ # Until verification passes, ANY exit (curl failure, set -e, signal, the
198+ # error exit below) removes the file, so a partial download can't be
199+ # silently picked up by a later run.
200+ trap 'rm -f "${CLAUDE_BINARY}"' EXIT INT TERM
201+ curl_https -fL -o "${CLAUDE_BINARY}" "${RELEASES_URL}/${VERSION}/linux-x64/claude"
202+ actual_sha="$(sha_of "${CLAUDE_BINARY}")"
203+ if [[ "${actual_sha}" != "${expected_sha}" ]]; then
204+ echo "ERROR: sha256 of ${CLAUDE_BINARY} is ${actual_sha} but the release manifest says ${expected_sha} — refusing to build." >&2
205+ exit 1
206+ fi
207+ trap - EXIT INT TERM
208+ log "Verified binary sha256 ${actual_sha}"
209+ fi
210+ # Optional out-of-band pin, checked even for a pre-existing binary: the
211+ # manifest shares an origin with the binary, so it can't defend against a
212+ # compromised endpoint — CLAUDE_SHA256 can.
213+ if [[ -n "${CLAUDE_SHA256}" && "$(sha_of "${CLAUDE_BINARY}")" != "${CLAUDE_SHA256}" ]]; then
214+ echo "ERROR: sha256 of ${CLAUDE_BINARY} does not match CLAUDE_SHA256 (${CLAUDE_SHA256}) — refusing to build." >&2
215+ exit 1
216+ fi
217+ chmod +x "${CLAUDE_BINARY}"
218+ log "Building and pushing ${IMAGE}"
219+ gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
220+ # Cloud Run requires linux/amd64. --platform forces it (e.g. when building on an
221+ # Apple Silicon Mac), and --provenance=false keeps buildx from wrapping the result
222+ # in an OCI image index that Cloud Run rejects ("manifest ... must support amd64/linux").
223+ docker build --platform=linux/amd64 --provenance=false \
224+ -f "${DOCKERFILE}" --build-arg CLAUDE_BINARY="${CLAUDE_BINARY}" -t "${IMAGE}" .
225+ docker push "${IMAGE}"
226+fi
227+
228+# ---- 4 VPC + Private Services Access (private-IP prerequisite) -------------
229+log "Creating VPC network and subnet"
230+if gcloud compute networks describe "${VPC_NETWORK}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
231+ skip "network ${VPC_NETWORK}"
232+else
233+ gcloud compute networks create "${VPC_NETWORK}" \
234+ --subnet-mode=custom --project="${PROJECT_ID}"
235+fi
236+
237+if gcloud compute networks subnets describe "${SUBNET}" \
238+ --region="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
239+ skip "subnet ${SUBNET}"
240+else
241+ gcloud compute networks subnets create "${SUBNET}" \
242+ --network="${VPC_NETWORK}" --region="${REGION}" \
243+ --range="${SUBNET_RANGE}" --project="${PROJECT_ID}"
244+fi
245+
246+log "Configuring Private Services Access (allocated range + VPC peering)"
247+if gcloud compute addresses describe "${PSA_RANGE_NAME}" \
248+ --global --project="${PROJECT_ID}" >/dev/null 2>&1; then
249+ skip "allocated range ${PSA_RANGE_NAME}"
250+else
251+ gcloud compute addresses create "${PSA_RANGE_NAME}" \
252+ --global --purpose=VPC_PEERING --prefix-length="${PSA_PREFIX_LENGTH}" \
253+ --network="${VPC_NETWORK}" --project="${PROJECT_ID}"
254+fi
255+
256+if gcloud services vpc-peerings list --network="${VPC_NETWORK}" --project="${PROJECT_ID}" \
257+ --format='value(peering)' 2>/dev/null | grep -q servicenetworking; then
258+ skip "servicenetworking VPC peering"
259+else
260+ gcloud services vpc-peerings connect \
261+ --service=servicenetworking.googleapis.com \
262+ --ranges="${PSA_RANGE_NAME}" \
263+ --network="${VPC_NETWORK}" --project="${PROJECT_ID}"
264+fi
265+
266+# ---- 4 Cloud SQL instance (private IP only) -------------------------------
267+log "Creating Cloud SQL instance ${DB_INSTANCE} (private IP only)"
268+if gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
269+ skip "instance ${DB_INSTANCE}"
270+else
271+ gcloud sql instances create "${DB_INSTANCE}" \
272+ --database-version="${DB_VERSION}" \
273+ --tier="${DB_TIER}" \
274+ --region="${REGION}" \
275+ --network="projects/${PROJECT_ID}/global/networks/${VPC_NETWORK}" \
276+ --no-assign-ip \
277+ --project="${PROJECT_ID}"
278+fi
279+
280+log "Creating database ${DB_NAME}"
281+if gcloud sql databases describe "${DB_NAME}" \
282+ --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
283+ skip "database ${DB_NAME}"
284+else
285+ gcloud sql databases create "${DB_NAME}" \
286+ --instance="${DB_INSTANCE}" --project="${PROJECT_ID}"
287+fi
288+
289+# hex (not base64) keeps the password URL-safe for the connection string below.
290+log "Creating database user ${DB_USER}"
291+DB_PASSWORD=""
292+if gcloud sql users list --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" \
293+ --format='value(name)' 2>/dev/null | grep -qx "${DB_USER}"; then
294+ if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
295+ skip "user ${DB_USER} (password unchanged; secret not rewritten)"
296+ else
297+ # Self-heal: a previous run died after creating the user but before writing
298+ # the connection-string secret, losing the only copy of the password. The
299+ # secret is the password's only consumer, so resetting it is safe and keeps
300+ # re-runs able to recover from any partial state.
301+ log "User ${DB_USER} exists but secret ${SECRET_NAME} is missing — resetting password"
302+ DB_PASSWORD="$(openssl rand -hex 24)"
303+ gcloud sql users set-password "${DB_USER}" \
304+ --instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \
305+ --project="${PROJECT_ID}"
306+ fi
307+else
308+ DB_PASSWORD="$(openssl rand -hex 24)"
309+ gcloud sql users create "${DB_USER}" \
310+ --instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \
311+ --project="${PROJECT_ID}"
312+fi
313+
314+# ---- 5 Connection string -> Secret Manager + secretAccessor ---------------
315+PRIVATE_IP="$(gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" \
316+ --format='value(ipAddresses[0].ipAddress)')"
317+
318+if [[ -n "${DB_PASSWORD}" ]]; then
319+ # direct private-IP form, ?sslmode=require (guide §4)
320+ CONN="postgres://${DB_USER}:${DB_PASSWORD}@${PRIVATE_IP}:5432/${DB_NAME}?sslmode=require"
321+ log "Storing connection string in Secret Manager secret ${SECRET_NAME}"
322+ if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
323+ printf '%s' "${CONN}" | gcloud secrets versions add "${SECRET_NAME}" \
324+ --data-file=- --project="${PROJECT_ID}"
325+ else
326+ printf '%s' "${CONN}" | gcloud secrets create "${SECRET_NAME}" \
327+ --replication-policy=automatic --data-file=- --project="${PROJECT_ID}"
328+ fi
329+else
330+ log "Skipping secret write (user already existed, password not available this run)"
331+fi
332+
333+if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
334+ log "Granting ${SA_EMAIL} secretAccessor on ${SECRET_NAME}"
335+ gcloud secrets add-iam-policy-binding "${SECRET_NAME}" \
336+ --member="serviceAccount:${SA_EMAIL}" \
337+ --role="roles/secretmanager.secretAccessor" \
338+ --condition=None --project="${PROJECT_ID}" >/dev/null
339+fi
340+
341+# JWT signing secret — generated once (re-runs do NOT rotate it).
342+log "Ensuring JWT signing secret ${JWT_SECRET_NAME} (§5)"
343+if gcloud secrets describe "${JWT_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
344+ skip "secret ${JWT_SECRET_NAME}"
345+else
346+ openssl rand -base64 32 | tr -d '\n' | gcloud secrets create "${JWT_SECRET_NAME}" \
347+ --replication-policy=automatic --data-file=- --project="${PROJECT_ID}"
348+fi
349+gcloud secrets add-iam-policy-binding "${JWT_SECRET_NAME}" \
350+ --member="serviceAccount:${SA_EMAIL}" \
351+ --role="roles/secretmanager.secretAccessor" \
352+ --condition=None --project="${PROJECT_ID}" >/dev/null
353+
354+# OIDC client secret — operator-created (the script can't generate it). Grant
355+# accessor here once it exists so the deploy step doesn't fail on permission.
356+if gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
357+ log "Granting ${SA_EMAIL} secretAccessor on ${OIDC_SECRET_NAME}"
358+ gcloud secrets add-iam-policy-binding "${OIDC_SECRET_NAME}" \
359+ --member="serviceAccount:${SA_EMAIL}" \
360+ --role="roles/secretmanager.secretAccessor" \
361+ --condition=None --project="${PROJECT_ID}" >/dev/null
362+fi
363+
364+# ---- 6 gateway.yaml -> Secret Manager (gateway-config) --------------------
365+# Published only when fully filled in: refuse to push a config that still has
366+# REPLACE_ME placeholders (checked on non-comment lines so commented examples
367+# and this file's header don't trip the guard).
368+log "Publishing ${GATEWAY_YAML} as Secret Manager secret ${CONFIG_SECRET} (§6)"
369+if [[ ! -f "${GATEWAY_YAML}" ]]; then
370+ echo " (skip) ${GATEWAY_YAML} not found — run 'cp gateway.yaml.example gateway.yaml', fill it in, then re-run (§6)."
371+elif grep -vE '^[[:space:]]*#' "${GATEWAY_YAML}" | grep -q 'REPLACE_ME'; then
372+ echo " (skip) ${GATEWAY_YAML} still has REPLACE_ME placeholders to fill:"
373+ grep -nE 'REPLACE_ME' "${GATEWAY_YAML}" | grep -vE '^[0-9]+:[[:space:]]*#' | sed 's/^/ /'
374+ echo " Fill them in, then re-run to publish ${CONFIG_SECRET}."
375+else
376+ if gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
377+ gcloud secrets versions add "${CONFIG_SECRET}" \
378+ --data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}"
379+ else
380+ gcloud secrets create "${CONFIG_SECRET}" --replication-policy=automatic \
381+ --data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}"
382+ fi
383+ gcloud secrets add-iam-policy-binding "${CONFIG_SECRET}" \
384+ --member="serviceAccount:${SA_EMAIL}" \
385+ --role="roles/secretmanager.secretAccessor" \
386+ --condition=None --project="${PROJECT_ID}" >/dev/null
387+fi
388+
389+# ---- 7 Cloud Run deploy (Direct VPC egress) -------------------------------
390+# Direct VPC egress (--network/--subnet/--vpc-egress) puts the service on the
391+# VPC so it reaches the Cloud SQL PRIVATE IP directly — matching the private-IP
392+# connection string in the postgres-url secret. private-ranges-only keeps public
393+# egress (Vertex, accounts.google.com) off the VPC, so no Cloud NAT is needed.
394+# We deliberately do NOT use --add-cloudsql-instances (that's the Auth Proxy /
395+# socket path, which would need a different connection string).
396+#
397+# Secrets: gateway.yaml is mounted as a FILE at /etc/claude (alone in its dir).
398+# The JWT / OIDC / Postgres secrets are injected as ENV VARS — Cloud Run cannot
399+# mount multiple secrets into one directory, and gateway.yaml references them via
400+# ${ENV_VAR}. (See the env-var names in gateway.yaml: GATEWAY_JWT_SECRET etc.)
401+#
402+# Self-gating: deploy only once its inputs exist (config secret published + the
403+# operator-provided OIDC client secret). On a first run these are missing and it
404+# cleanly skips.
405+RUN_URL=""
406+missing=""
407+gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${CONFIG_SECRET}"
408+gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${OIDC_SECRET_NAME}"
409+# Also gate on the postgres-url secret (referenced by --set-secrets below): if it
410+# is somehow absent, skip with a clear message rather than failing the deploy with
411+# a raw Cloud Run missing-secret error.
412+gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${SECRET_NAME}"
413+
414+if [[ "${DEPLOY}" != "1" ]]; then
415+ log "Skipping Cloud Run deploy (DEPLOY=${DEPLOY}) (§7)"
416+elif [[ -n "${missing// }" ]]; then
417+ log "Skipping Cloud Run deploy — missing secret(s):${missing} (§7)"
418+ echo " Fill ${GATEWAY_YAML} and re-run to publish ${CONFIG_SECRET}; create ${OIDC_SECRET_NAME}"
419+ echo " from the Google OAuth client. Then re-run to deploy."
420+else
421+ SECRET_MOUNTS="/etc/claude/gateway.yaml=${CONFIG_SECRET}:latest" # file mount (alone in /etc/claude)
422+ SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_JWT_SECRET=${JWT_SECRET_NAME}:latest" # env var
423+ SECRET_MOUNTS="${SECRET_MOUNTS},OIDC_CLIENT_SECRET=${OIDC_SECRET_NAME}:latest" # env var
424+ SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_POSTGRES_URL=${SECRET_NAME}:latest" # env var
425+
426+ log "Deploying Cloud Run service ${SERVICE_NAME} (§7b, Direct VPC egress)"
427+ # Deploy private (--no-allow-unauthenticated avoids the interactive prompt and
428+ # keeps allUsers OUT of the deploy, so a Domain-Restricted-Sharing org doesn't
429+ # fail the deploy on the IAM step). Public access is attempted separately below.
430+ #
431+ # --ingress is passed EXPLICITLY because it is sticky across redeploys (omitting
432+ # it keeps the previous value). The default, internal, keeps the *.run.app URL
433+ # off the public internet — reachable only from this VPC, or from corp networks
434+ # with the PSC endpoint + private run.app DNS plumbing (see terraform/README.md
435+ # "Private access"). Public ingress cannot serve clients (see the INGRESS
436+ # guard at the top of this script), so the two-pass OAuth bootstrap has to be
437+ # completed from inside the VPC (or a PSC-connected corp network). Use
438+ # internal-and-cloud-load-balancing instead if you front the service with
439+ # your own internal ALB.
440+ #
441+ # --timeout=3600 raises Cloud Run's default 300s request timeout, which would
442+ # otherwise cut off long streaming /v1/messages responses mid-stream.
443+ #
444+ # --max-instances bounds the Postgres connection footprint: each instance
445+ # opens a pool of up to 5 connections (store.max_connections default) and
446+ # db-g1-small caps at ~50 max_connections, so the default ceiling of 100
447+ # instances would crash-loop new instances under load. Keep
448+ # max-instances × 5 below the DB tier's max_connections; raise the DB tier
449+ # (or set store.max_connections lower) before raising this.
450+ gcloud run deploy "${SERVICE_NAME}" \
451+ --image="${IMAGE}" \
452+ --region="${REGION}" \
453+ --service-account="${SA_EMAIL}" \
454+ --min-instances=1 \
455+ --max-instances="${MAX_INSTANCES}" \
456+ --port=8080 \
457+ --timeout=3600 \
458+ --ingress="${INGRESS}" \
459+ --network="${VPC_NETWORK}" \
460+ --subnet="${SUBNET}" \
461+ --vpc-egress=private-ranges-only \
462+ --set-secrets="${SECRET_MOUNTS}" \
463+ --no-allow-unauthenticated \
464+ --project="${PROJECT_ID}"
465+
466+ # The gateway runs its OWN OIDC, so the Cloud Run IAM layer must allow
467+ # unauthenticated. Attempt it separately and tolerate failure: Domain Restricted
468+ # Sharing (iam.allowedPolicyMemberDomains) blocks allUsers in hardened orgs.
469+ log "Granting public invoker (allUsers) — required for the gateway's OIDC login"
470+ if gcloud run services add-iam-policy-binding "${SERVICE_NAME}" \
471+ --region="${REGION}" --member=allUsers --role=roles/run.invoker \
472+ --project="${PROJECT_ID}" >/dev/null 2>&1; then
473+ echo " public invoker granted."
474+ else
475+ echo " WARN: allUsers rejected (likely Domain Restricted Sharing). The service is"
476+ echo " deployed but the invoker IAM check is still enabled, so requests 403"
477+ echo " before reaching the container. Preferred fix (where available):"
478+ echo " gcloud run services update ${SERVICE_NAME} --no-invoker-iam-check \\"
479+ echo " --region=${REGION} --project=${PROJECT_ID}"
480+ echo " Alternatively: request a DRS exception for ${SERVICE_NAME}, or use the GKE"
481+ echo " track, which exposes the gateway at the network layer with no allUsers"
482+ echo " binding. An LB is NOT a fix — it does not bypass the invoker IAM check."
483+ fi
484+
485+ RUN_URL="$(gcloud run services describe "${SERVICE_NAME}" --region="${REGION}" \
486+ --project="${PROJECT_ID}" --format='value(status.url)')"
487+ log "Cloud Run URL: ${RUN_URL}"
488+
489+ # public_url is now required (config validation refuses a non-loopback bind
490+ # without it), so the template ships a placeholder for the first pass. Once we
491+ # know the real URL, warn on any mismatch so the operator doesn't leave the
492+ # placeholder — or a stale hostname — in place. Normalize quotes / inline
493+ # comments / a trailing slash so schema-equivalent spellings compare equal.
494+ # Only checked with internal ingress, where public_url should be the run.app
495+ # URL; behind an internal ALB it is the ALB hostname, which this script
496+ # cannot know.
497+ CFG_PUBLIC_URL="$(grep -E '^[[:space:]]*public_url:' "${GATEWAY_YAML}" 2>/dev/null \
498+ | head -1 \
499+ | sed -E 's/^[[:space:]]*public_url:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]*$//' \
500+ || true)"
501+ CFG_PUBLIC_URL="${CFG_PUBLIC_URL#[\'\"]}"; CFG_PUBLIC_URL="${CFG_PUBLIC_URL%[\'\"]}"
502+ CFG_PUBLIC_URL="${CFG_PUBLIC_URL%/}"
503+ if [[ "${INGRESS}" == "internal" && -n "${RUN_URL}" && "${CFG_PUBLIC_URL}" != "${RUN_URL%/}" ]]; then
504+ echo " NOTE — ${GATEWAY_YAML} has public_url: ${CFG_PUBLIC_URL:-<unset>}"
505+ echo " but this service's URL is ${RUN_URL}."
506+ echo " Set listen.public_url to ${RUN_URL} (or your LB hostname) and re-run."
507+ fi
508+
509+ if [[ -n "${RUN_URL}" ]]; then
510+ # gcloud run deploy already fails the script if the revision can't boot (it
511+ # waits for the Ready condition), so what's left to verify is that the
512+ # gateway is serving. The OAuth discovery document below returns 200 only
513+ # after config load, OIDC discovery, upstream construction, and Postgres
514+ # migration all succeed, so it doubles as an end-to-end boot check (the
515+ # readiness probe proper is GET /readyz). With internal ingress the URL is
516+ # reachable only from inside the VPC (or a PSC-connected corp network), so
517+ # verification is left to the operator rather than attempted from here.
518+ log "Verify the gateway is serving (from inside the VPC, or a PSC-connected corp network):"
519+ echo " curl -s ${RUN_URL}/.well-known/oauth-authorization-server"
520+ echo " If it isn't responding yet, check logs:"
521+ echo " gcloud run services logs read ${SERVICE_NAME} --region=${REGION} --project=${PROJECT_ID}"
522+
523+ log "Finish the OAuth bootstrap:"
524+ echo " 1. Register this redirect URI on the Google OAuth client: ${RUN_URL}/oauth/callback"
525+ echo " 2. Set listen.public_url in ${GATEWAY_YAML} to ${RUN_URL}, then re-run: INGRESS=${INGRESS} ./setup.sh"
526+ echo " (republishes ${CONFIG_SECRET} and redeploys so the IdP redirect_uri matches)."
527+ echo " With INGRESS=internal-and-cloud-load-balancing, use your internal ALB hostname"
528+ echo " instead of the run.app URL in both steps."
529+ fi
530+fi
531+
532+# ---- summary ----------------------------------------------------------------
533+cat <<EOF
534+
535+==> Done.
536+
537+ Service account ${SA_EMAIL}
538+ roles: aiplatform.user, secretmanager.secretAccessor
539+ Image ${IMAGE}
540+ Instance ${DB_INSTANCE}
541+ Connection name ${PROJECT_ID}:${REGION}:${DB_INSTANCE}
542+ Private IP ${PRIVATE_IP}
543+ Database / user ${DB_NAME} / ${DB_USER}
544+ Secrets ${SECRET_NAME}, ${JWT_SECRET_NAME}, ${CONFIG_SECRET}
545+ Cloud Run service ${SERVICE_NAME} -> ${RUN_URL:-(not deployed yet)} (ingress: ${INGRESS})
546+
547+Next steps (see https://code.claude.com/docs/en/claude-apps-gateway-on-gcp):
548+ - Create the one operator-provided secret (from the Google Cloud Console OAuth client):
549+ printf '%s' "<client-secret>" | gcloud secrets create ${OIDC_SECRET_NAME} \\
550+ --data-file=- --project="${PROJECT_ID}"
551+ setup.sh grants ${SA_EMAIL} secretAccessor on it on the next re-run.
552+ - Fill in the REPLACE_ME values in ${GATEWAY_YAML}, then re-run: setup.sh publishes
553+ ${CONFIG_SECRET} and deploys ${SERVICE_NAME} once both secrets exist.
554+ - After the first deploy: set listen.public_url to the Cloud Run URL above (or your
555+ internal ALB hostname) and register <url>/oauth/callback on the Google OAuth client,
556+ then re-run to redeploy.
557+ - The gateway runs its own schema migrations at boot, so ${DB_USER} needs CREATE TABLE.
558+EOF
@@ -1,0 +1,13 @@
1+# Never commit state (contains secrets) or local var files
2+*.tfstate
3+*.tfstate.*
4+.terraform/
5+terraform.tfvars
6+*.auto.tfvars
7+crash.log
8+
9+# The lock file holds no secrets. It's ignored here so consumers who copy this
10+# example into their own repo generate (and commit) their own platform-complete
11+# lock at first init — committing one from this repo would carry only one
12+# platform's provider hashes.
13+.terraform.lock.hcl
@@ -1,0 +1,160 @@
1+# Claude Gateway — Terraform (Cloud Run)
2+
3+Terraform equivalent of `../setup.sh`. Lets end-users provision and manage
4+the gateway with `terraform apply`. Covers the same scope ([walkthrough](https://code.claude.com/docs/en/claude-apps-gateway-on-gcp) §1–7): APIs →
5+service account + IAM → Artifact Registry repo → VPC + Private Services Access →
6+private-IP Cloud SQL (PG16) → secrets → Cloud Run with Direct VPC egress.
7+
8+## Files
9+
10+| File | Purpose |
11+|------|---------|
12+| `versions.tf` | Provider pins (google, random) |
13+| `variables.tf` | All inputs (defaults match `setup.sh`'s) |
14+| `main.tf` | Resources |
15+| `outputs.tf` | Service URL, OAuth redirect URI, SA, DB info |
16+| `terraform.tfvars.example` | Copy to `terraform.tfvars` and edit |
17+
18+## Prerequisites
19+
20+1. **`../gateway.yaml` created and filled in** — copy the template first:
21+ `cp ../gateway.yaml.example ../gateway.yaml`, then replace every `REPLACE_ME`
22+ (Terraform reads this file and enforces no `REPLACE_ME` via a precondition).
23+ Leave `public_url` at its placeholder for the first apply; set it to the
24+ `run.app` URL (the `service_url` output) or your LB hostname and re-apply.
25+ `gateway.yaml` is gitignored; the committed template is `gateway.yaml.example`.
26+2. A **remote backend** for shared use (see below). State holds secrets — never commit it.
27+
28+## Deploy
29+
30+Terraform creates the Artifact Registry repo but does **not** build/push the
31+image, so the apply is two passes: a targeted apply to create the repo, then
32+build/push, then the full apply.
33+
34+```bash
35+cp terraform.tfvars.example terraform.tfvars # edit it
36+terraform init
37+
38+# 1. Create just the Artifact Registry repo (the -target warning is expected):
39+terraform apply -target=google_artifact_registry_repository.repo
40+
41+# 2. Download the public Claude Code linux-x64 release binary (it includes the
42+# `gateway` subcommand; the Dockerfile picks it up at gcp/claude), verify its
43+# sha256 against the release manifest, then build and push the image:
44+BASE="https://downloads.claude.ai/claude-code-releases"
45+VERSION="$(curl -fsSL --proto '=https' "${BASE}/latest")"
46+curl -fL --proto '=https' --proto-redir '=https' -o ../claude \
47+ "${BASE}/${VERSION}/linux-x64/claude"
48+WANT="$(curl -fsSL --proto '=https' "${BASE}/${VERSION}/manifest.json" \
49+ | tr -d '[:space:]' | grep -oE '"linux-x64"[^}]*' | grep -oE '[a-f0-9]{64}' | head -1)"
50+[ "$(openssl dgst -sha256 ../claude | awk '{print $NF}')" = "${WANT}" ] \
51+ && echo "sha256 OK" || { echo "checksum mismatch" >&2; rm -f ../claude; }
52+gcloud auth configure-docker us-east5-docker.pkg.dev --quiet
53+docker build --platform=linux/amd64 --provenance=false \
54+ -f ../Dockerfile -t "us-east5-docker.pkg.dev/<project>/claude-gateway/gateway:${VERSION}" ..
55+docker push "us-east5-docker.pkg.dev/<project>/claude-gateway/gateway:${VERSION}"
56+
57+# 3. Full apply:
58+terraform apply
59+```
60+
61+(`../setup.sh` §3 automates the same download-and-verify.)
62+
63+Set in `terraform.tfvars`:
64+
65+- `project_id`, `region`
66+- `image_tag` (after building/pushing — step 2 above)
67+- **`oidc_client_secret`** — required (the Cloud Run service mounts `latest` of
68+ this secret; with no version the deploy fails). Terraform creates the
69+ secret + version from it.
70+- `invoker_iam_disabled` / `allow_unauthenticated` — the gateway runs its own
71+ OIDC, so the Cloud Run invoker IAM check must be opened or disabled.
72+ **Preferred:** `invoker_iam_disabled = true` (no `allUsers` binding; works
73+ under Domain Restricted Sharing). **Fallback:** `allow_unauthenticated = true`
74+ grants `allUsers` `run.invoker` — fine on a normal org, but DRS orgs reject
75+ `allUsers` (set it `false` there, since an LB does **not** bypass the IAM
76+ check). If both paths are blocked by org policy, use the GKE track.
77+- `ingress` — defaults to **internal-only** (no public URL). Claude Code's `/login`
78+ only accepts gateway hosts on private addresses, so public ingress cannot serve
79+ clients; the two-pass OAuth bootstrap must be completed from inside the VPC (or a
80+ PSC-connected corp network). See "Private access" below.
81+
82+Tear down a trial with `terraform destroy`: set `deletion_protection = false`,
83+run `terraform apply` to record that in state (the provider checks the value in
84+**state**, not config, so destroy would still refuse otherwise), then `terraform
85+destroy`. The destroy will stop at the VPC network
86+because the Private Services Access peering is intentionally left in place
87+(`deletion_policy = ABANDON` — see Guard rails below); finish by deleting the
88+peering manually once the Cloud SQL instance is gone, then re-run destroy:
89+
90+```bash
91+gcloud services vpc-peerings delete --service=servicenetworking.googleapis.com \
92+ --network=cc-gateway-vpc --project=<project>
93+terraform destroy
94+```
95+
96+## Guard rails
97+
98+Tuned so accidental deletion is hard but greenfield teardown stays easy:
99+
100+- `deletion_protection = true` (variable, default true) on Cloud SQL and Cloud Run —
101+ blocks accidental deletion; set `false` when you intend to `terraform destroy`.
102+- `disable_on_destroy = false` on APIs — tearing down config never disables APIs.
103+- `deletion_policy = ABANDON` on the PSA peering — never tears down the
104+ service-networking peering automatically (it's shared by every private-IP
105+ service on the VPC). On the dedicated VPC this module creates, that means
106+ `terraform destroy` stops at the network step; delete the peering manually
107+ per the teardown note above.
108+- IAM uses non-authoritative `_member` resources, so other project/secret bindings
109+ are never clobbered.
110+
111+## Private access (internal ingress) — the default
112+
113+By default the service has **no public URL** (`ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY"`),
114+and there is no public-ingress option: Claude Code's `/login` rejects gateway hosts that
115+resolve to public addresses, so public exposure cannot serve clients. Reach the service
116+from inside the VPC, or via the private-access plumbing below.
117+
118+With internal-only ingress, `public_url` stays the `run.app` URL (Google-managed cert) —
119+**no load balancer or your own certificate required**. But internal ingress alone does
120+**not** let corporate on-prem clients reach `run.app`; that needs **operator /
121+network-team-owned** plumbing that Cloud Run does **not** create for you (validate it's in
122+place before relying on internal ingress):
123+
124+1. A **Private Service Connect endpoint** for Google APIs (an internal VIP in the VPC).
125+2. A **Cloud DNS private zone for `run.app`** resolving `*.run.app` to that endpoint IP.
126+3. **On-prem routing** to the endpoint over Cloud VPN / Interconnect.
127+
128+This is normally managed centrally in the network/hub project, so the module does not
129+provision it. See [Private networking and Cloud Run](https://cloud.google.com/run/docs/securing/private-networking).
130+For a greenfield trial without this plumbing, complete the OAuth bootstrap from inside
131+the VPC — e.g. a browser proxied through an in-VPC VM (SSH SOCKS tunnel over IAP).
132+
133+For a **custom internal hostname or your own TLS cert**, use
134+`INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER` and front the service with your own internal
135+Application Load Balancer (also not provisioned by this module).
136+
137+## Remote state (recommended for teams)
138+
139+Add a backend so state is shared and locked (and out of git):
140+
141+```hcl
142+# backend.tf
143+terraform {
144+ backend "gcs" {
145+ bucket = "<your-tf-state-bucket>"
146+ prefix = "claude-gateway/cloudrun"
147+ }
148+}
149+```
150+
151+## After deploy
152+
153+- `terraform output service_url` / `oauth_redirect_uri`.
154+- Register the redirect URI on the Google OAuth client and make sure
155+ `../gateway.yaml` `public_url` matches the host.
156+- Notes: Terraform does not build the image. To ship a new gateway version,
157+ rerun the docker build/push under a new tag and bump `image_tag` — a bare
158+ re-apply under an unchanged tag does **not** roll a new revision (Cloud Run
159+ resolves the tag to a digest only at revision creation, and an unchanged
160+ `image` attribute means no new revision).
@@ -1,0 +1,399 @@
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" # Vertex 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 (Vertex, 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+}
@@ -1,0 +1,34 @@
1+output "service_url" {
2+ description = "Cloud Run service URL."
3+ value = google_cloud_run_v2_service.gateway.uri
4+}
5+
6+output "oauth_redirect_uri" {
7+ description = "Register this exact URI on the Google OAuth client, and ensure gateway.yaml public_url matches the host."
8+ value = "${google_cloud_run_v2_service.gateway.uri}/oauth/callback"
9+}
10+
11+output "service_account_email" {
12+ description = "Gateway runtime service account."
13+ value = google_service_account.gateway.email
14+}
15+
16+output "image" {
17+ description = "Image the service runs (build/push this separately — see README)."
18+ value = local.image
19+}
20+
21+output "db_connection_name" {
22+ description = "Cloud SQL instance connection name (project:region:instance)."
23+ value = google_sql_database_instance.db.connection_name
24+}
25+
26+output "db_private_ip" {
27+ description = "Cloud SQL private IP."
28+ value = google_sql_database_instance.db.private_ip_address
29+}
30+
31+output "public_invoker_granted" {
32+ description = "Whether the allUsers run.invoker binding was applied (false when invoker_iam_disabled handles public access instead, or on Domain-Restricted-Sharing orgs)."
33+ value = length(google_cloud_run_v2_service_iam_member.public) > 0
34+}
@@ -1,0 +1,26 @@
1+# Copy to terraform.tfvars and edit. terraform.tfvars is gitignored (see .gitignore).
2+
3+project_id = "your-gcp-project-id"
4+region = "us-east5"
5+
6+image_tag = "<version>" # REQUIRED — the Claude Code release version you build and push as linux/amd64 (see README Deploy)
7+
8+# Public access at the Cloud Run IAM layer (the gateway runs its own OIDC):
9+# Preferred — disable the invoker check: no allUsers binding, works under Domain
10+# Restricted Sharing. Needs google provider >= 6.8 and the feature enabled for your org:
11+# invoker_iam_disabled = true
12+# Fallback — grant allUsers (fine on a normal org; Domain Restricted Sharing rejects it,
13+# so there prefer invoker_iam_disabled, or use a DRS exception / GKE):
14+allow_unauthenticated = true
15+
16+# Network reachability — a separate axis from the IAM choice above. Default is internal-only:
17+# no public URL (Claude Code's /login only accepts gateway hosts on private addresses, so
18+# public ingress cannot serve clients); corp on-prem reaches run.app via a PSC endpoint +
19+# private run.app DNS — see README "Private access" for the prerequisites. The only
20+# alternative to the internal-only default:
21+# ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" # only if you front it with your OWN internal ALB (custom hostname/cert; not provisioned here)
22+
23+# Google OAuth client secret: REQUIRED — uncomment and set it (Terraform creates the
24+# secret version; the Cloud Run service mounts `latest`, so without a version the
25+# deploy fails). Leave empty only if you add the secret version out-of-band.
26+# oidc_client_secret = "GOCSPX-..."
@@ -1,0 +1,184 @@
1+# Inputs — mirror the env-overridable knobs in setup.sh (same defaults).
2+
3+variable "project_id" {
4+ description = "GCP project ID."
5+ type = string
6+}
7+
8+variable "region" {
9+ description = "Infra region for Artifact Registry, Cloud SQL, subnet, and Cloud Run. (Vertex region is set separately inside gateway.yaml.)"
10+ type = string
11+ default = "us-east5"
12+}
13+
14+# ── Service account (§2) ────────────────────────────────────────────────────
15+variable "sa_name" {
16+ description = "Service account account_id (the part before @)."
17+ type = string
18+ default = "claude-gateway"
19+}
20+
21+# ── Image (§3) ──────────────────────────────────────────────────────────────
22+# Terraform creates the Artifact Registry repo but does NOT build/push the image
23+# (that's a docker build step — see README). It references the image by tag.
24+variable "ar_repo" {
25+ description = "Artifact Registry Docker repository ID."
26+ type = string
27+ default = "claude-gateway"
28+}
29+
30+variable "image_name" {
31+ description = "Image name within the repo."
32+ type = string
33+ default = "gateway"
34+}
35+
36+variable "image_tag" {
37+ description = "Image tag — the Claude Code release version you build and push (must already be pushed as linux/amd64). See the README Deploy section for the build command."
38+ type = string
39+ validation {
40+ condition = can(regex("^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$", var.image_tag))
41+ error_message = "image_tag must be a valid OCI tag — set it to the Claude Code release version you pushed (the '<version>' in terraform.tfvars.example is a placeholder)."
42+ }
43+}
44+
45+# ── Networking (§4) ─────────────────────────────────────────────────────────
46+variable "vpc_network" {
47+ description = "Custom VPC network name."
48+ type = string
49+ default = "cc-gateway-vpc"
50+}
51+
52+variable "subnet" {
53+ description = "Subnet name (Cloud Run Direct VPC egress attaches here)."
54+ type = string
55+ default = "cc-gateway-subnet"
56+}
57+
58+variable "subnet_range" {
59+ description = "Subnet primary CIDR."
60+ type = string
61+ default = "10.0.0.0/24"
62+}
63+
64+variable "psa_prefix_length" {
65+ description = "Prefix length for the Private Services Access allocated range (/16 is GCP's recommendation)."
66+ type = number
67+ default = 16
68+}
69+
70+# ── Cloud SQL (§4) ──────────────────────────────────────────────────────────
71+variable "db_instance" {
72+ description = "Cloud SQL instance name."
73+ type = string
74+ default = "claude-gateway-db"
75+}
76+
77+variable "db_version" {
78+ description = "Postgres major version. The gateway supports PostgreSQL 14 or newer; 16 is the recommended default."
79+ type = string
80+ default = "POSTGRES_16"
81+}
82+
83+variable "db_tier" {
84+ description = "Cloud SQL machine tier."
85+ type = string
86+ default = "db-g1-small"
87+}
88+
89+variable "db_name" {
90+ description = "Database name."
91+ type = string
92+ default = "claude_gateway"
93+}
94+
95+variable "db_user" {
96+ description = "Database user (the gateway connects as this role)."
97+ type = string
98+ default = "gateway"
99+}
100+
101+# ── Secrets (§5 / §6) ─────────────────────────────────────────────────────
102+variable "secret_name" {
103+ description = "Secret Manager secret holding the Postgres connection string."
104+ type = string
105+ default = "gateway-postgres-url"
106+}
107+
108+variable "jwt_secret_name" {
109+ description = "Secret Manager secret holding the session JWT signing key."
110+ type = string
111+ default = "gateway-jwt-secret"
112+}
113+
114+variable "oidc_secret_name" {
115+ description = "Secret Manager secret holding the Google OAuth client secret."
116+ type = string
117+ default = "gateway-oidc-client-secret"
118+}
119+
120+variable "config_secret_name" {
121+ description = "Secret Manager secret holding gateway.yaml (mounted at /etc/claude/gateway.yaml)."
122+ type = string
123+ default = "gateway-config"
124+}
125+
126+variable "oidc_client_secret" {
127+ description = "Google OAuth client secret value. Leave empty to NOT manage the version via Terraform (only if you add the secret version out-of-band — without one the deploy fails)."
128+ type = string
129+ default = ""
130+ sensitive = true
131+}
132+
133+variable "gateway_config_path" {
134+ description = "Path to gateway.yaml. Empty = ../gateway.yaml relative to this module."
135+ type = string
136+ default = ""
137+}
138+
139+# ── Cloud Run (§7) ──────────────────────────────────────────────────────────
140+variable "service_name" {
141+ description = "Cloud Run service name."
142+ type = string
143+ default = "claude-gateway"
144+}
145+
146+variable "min_instances" {
147+ description = "Minimum Cloud Run instances (1 avoids cold OIDC discovery)."
148+ type = number
149+ default = 1
150+}
151+
152+variable "max_instances" {
153+ description = "Maximum Cloud Run instances. Each instance opens a Postgres pool of up to 5 connections (the gateway's store.max_connections default) and db-g1-small caps at ~50 max_connections — keep max_instances × 5 below the DB tier's limit, or raise the tier before raising this."
154+ type = number
155+ default = 8
156+}
157+
158+variable "ingress" {
159+ description = "Cloud Run ingress — Claude Code's /login only accepts gateway hosts on private addresses, so public ingress cannot serve clients: INGRESS_TRAFFIC_INTERNAL_ONLY (default; no public URL — VPC-only; reaches corp on-prem only with the private-access prerequisites in the README; public_url stays the run.app URL, so no LB or custom cert needed) or INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER (front with your own internal ALB for a custom hostname/cert)."
160+ type = string
161+ default = "INGRESS_TRAFFIC_INTERNAL_ONLY"
162+ validation {
163+ condition = contains(["INGRESS_TRAFFIC_INTERNAL_ONLY", "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"], var.ingress)
164+ error_message = "ingress must be INGRESS_TRAFFIC_INTERNAL_ONLY or INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER — Claude Code only connects to gateways on private addresses."
165+ }
166+}
167+
168+variable "invoker_iam_disabled" {
169+ description = "PREFERRED public-access path: disable the Cloud Run invoker IAM check so requests reach the container with no allUsers binding (works under Domain Restricted Sharing). Real auth stays the gateway's own OIDC. When true, the allUsers grant below is skipped. May be blocked by org policy constraints/run.managed.requireInvokerIam, or unavailable for the org (\"invoker_iam_disabled is not currently available for your organization\") — then fall back to allow_unauthenticated. Requires google provider >= 6.8."
170+ type = bool
171+ default = false
172+}
173+
174+variable "allow_unauthenticated" {
175+ description = "Fallback public-access path: grant allUsers run.invoker (the gateway needs the IAM layer open for its own OIDC). Prefer invoker_iam_disabled. Domain Restricted Sharing orgs reject allUsers — set false there and use invoker_iam_disabled, a DRS exception, or GKE."
176+ type = bool
177+ default = true
178+}
179+
180+variable "deletion_protection" {
181+ description = "Provider-level deletion protection on Cloud SQL and Cloud Run. Keep true to avoid accidental deletion of the running deployment."
182+ type = bool
183+ default = true
184+}
@@ -1,0 +1,19 @@
1+# Provider + version pins for the Claude Gateway Cloud Run deployment.
2+terraform {
3+ required_version = ">= 1.5"
4+ required_providers {
5+ google = {
6+ source = "hashicorp/google"
7+ version = ">= 6.8, < 7.0" # 6.8 adds invoker_iam_disabled on google_cloud_run_v2_service
8+ }
9+ random = {
10+ source = "hashicorp/random"
11+ version = ">= 3.5"
12+ }
13+ }
14+}
15+
16+provider "google" {
17+ project = var.project_id
18+ region = var.region
19+}