surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
| 1 | # Claude Gateway — 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). |