Compare commits

...

55 Commits

Author SHA1 Message Date
GitHub Actions
07dcb0e135 chore: Update CHANGELOG.md and feed.xml 2026-07-18 01:20:23 +00:00
GitHub Actions
67f390c9a0 chore: Update CHANGELOG.md and feed.xml
Fixes #123
2026-07-17 00:26:21 +00:00
GitHub Actions
c39cb0f14b chore: Update CHANGELOG.md and feed.xml 2026-07-15 23:02:29 +00:00
GitHub Actions
b7784f2c63 chore: Update CHANGELOG.md and feed.xml 2026-07-14 23:45:20 +00:00
GitHub Actions
c9181ca6eb chore: Update CHANGELOG.md and feed.xml 2026-07-14 17:59:31 +00:00
GitHub Actions
988b3e5643 chore: Update CHANGELOG.md and feed.xml 2026-07-14 06:36:21 +00:00
GitHub Actions
1fb278b85d chore: Update CHANGELOG.md and feed.xml 2026-07-14 01:10:34 +00:00
GitHub Actions
d4d8fbbb33 chore: Update CHANGELOG.md and feed.xml 2026-07-11 00:52:04 +00:00
GitHub Actions
15a21e1b4e chore: Update CHANGELOG.md and feed.xml 2026-07-09 23:34:23 +00:00
GitHub Actions
be02c39841 chore: Update CHANGELOG.md and feed.xml 2026-07-08 21:21:58 +00:00
GitHub Actions
d0f5bebd40 chore: Update CHANGELOG.md and feed.xml 2026-07-08 00:27:43 +00:00
GitHub Actions
00ea292447 chore: Update CHANGELOG.md and feed.xml 2026-07-07 21:06:03 +00:00
GitHub Actions
7930e1c82d chore: Update CHANGELOG.md and feed.xml 2026-07-06 22:51:11 +00:00
GitHub Actions
c489eb25c7 chore: Update CHANGELOG.md and feed.xml 2026-07-03 23:50:30 +00:00
GitHub Actions
1322e9bacc chore: Update CHANGELOG.md and feed.xml 2026-07-03 16:52:26 +00:00
GitHub Actions
125d63feae chore: Update CHANGELOG.md and feed.xml 2026-07-02 23:35:12 +00:00
GitHub Actions
5dc12eb281 chore: Update CHANGELOG.md and feed.xml 2026-07-02 22:54:58 +00:00
GitHub Actions
75709eacf1 chore: Update CHANGELOG.md and feed.xml 2026-07-01 20:45:29 +00:00
GitHub Actions
a56ff02e85 chore: Update CHANGELOG.md and feed.xml 2026-06-30 17:56:30 +00:00
GitHub Actions
c80896ca84 chore: Update CHANGELOG.md and feed.xml 2026-06-29 23:27:25 +00:00
Roy Arsan
3c3558207e Merge pull request #72363 from anthropics/royarsan/gateway-gcp-agent-platform
Gateway GCP example: Agent Platform rebrand and README cleanup
2026-06-29 18:26:37 -05:00
Roy Arsan
f605f0b68d Gateway GCP example: Agent Platform rebrand and README cleanup
- Update Vertex AI prose references to Agent Platform across the example
  (READMEs, script/terraform comments, config template). Functional
  identifiers are unchanged: the 'provider: vertex' config key,
  roles/aiplatform.user, aiplatform.googleapis.com, and the terraform
  resource label (renaming it would recreate the IAM binding in existing
  states). First mentions keep '(formerly Vertex AI)' for searchability.
- Drop the README's self-referential public-mirror link.
2026-06-29 23:13:09 +00:00
Roy Arsan
27e561ba3d Merge pull request #72361 from anthropics/royarsan/gateway-gcp-example
Add Claude Gateway on GCP example deployment assets
2026-06-29 17:45:07 -05:00
Roy Arsan
6234fa8f14 Add Claude Gateway on GCP example deployment assets
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.
2026-06-29 21:37:59 +00:00
GitHub Actions
01f1617f14 chore: Update CHANGELOG.md and feed.xml 2026-06-26 21:29:36 +00:00
GitHub Actions
f0919a1a72 chore: Update CHANGELOG.md and feed.xml 2026-06-25 21:45:51 +00:00
GitHub Actions
0bd954331e chore: Update CHANGELOG.md and feed.xml 2026-06-24 21:58:06 +00:00
GitHub Actions
5c1517a21b chore: Update CHANGELOG.md and feed.xml 2026-06-24 15:53:49 +00:00
GitHub Actions
2aa6ef3d35 chore: Update CHANGELOG.md and feed.xml 2026-06-23 21:03:40 +00:00
GitHub Actions
12281998d8 chore: Update CHANGELOG.md and feed.xml 2026-06-22 20:37:27 +00:00
GitHub Actions
b4073894cd chore: Update CHANGELOG.md and feed.xml 2026-06-20 20:59:12 +00:00
GitHub Actions
c487902a53 chore: Update CHANGELOG.md and feed.xml 2026-06-19 01:20:44 +00:00
Ashwin Bhat
baf38ddaaa Fix lock-closed-issues workflow: use search API instead of offset pagination (#69470)
The workflow has been failing daily since 2026-04-27 with HTTP 422
"Pagination with the page parameter is not supported for large
datasets" at page=100. The repo now has ~58k closed issues and the
script was paging past ~10k already-locked ones every run before
reaching any candidates.

Replace listForRepo + page=N with the search API
(is:issue is:closed is:unlocked updated:<cutoff), which returns only
the issues that actually need locking. Cap at 250/run with a 1s sleep
between locks to stay under secondary rate limits.

Claude-Session: https://claude.ai/code/session_016EWY3FKCJyfUdCAZkXfi7i
2026-06-18 17:15:49 -07:00
GitHub Actions
4fa369b5b3 chore: Update CHANGELOG.md and feed.xml 2026-06-18 22:03:35 +00:00
william qian
423563cfe3 Update frontend-design skill, bump plugin to 1.1.0 (#69226) 2026-06-17 19:25:44 -07:00
GitHub Actions
0047022a46 chore: Update CHANGELOG.md and feed.xml 2026-06-17 22:07:35 +00:00
GitHub Actions
843959fad9 chore: Update CHANGELOG.md and feed.xml 2026-06-16 20:44:34 +00:00
GitHub Actions
1b7380874c chore: Update CHANGELOG.md and feed.xml 2026-06-16 20:22:06 +00:00
GitHub Actions
64ceb97caa chore: Update CHANGELOG.md and feed.xml 2026-06-15 21:35:48 +00:00
GitHub Actions
ca9f6045fc chore: Update CHANGELOG.md and feed.xml 2026-06-12 21:53:22 +00:00
GitHub Actions
ee81682a72 chore: Update CHANGELOG.md and feed.xml 2026-06-12 04:23:45 +00:00
GitHub Actions
5754a8bd4f chore: Update CHANGELOG.md and feed.xml 2026-06-12 01:16:30 +00:00
GitHub Actions
3a7c736101 chore: Update CHANGELOG.md and feed.xml 2026-06-11 05:41:48 +00:00
GitHub Actions
ca34f27543 chore: Update CHANGELOG.md and feed.xml 2026-06-10 20:44:09 +00:00
GitHub Actions
1c5f951a48 chore: Update CHANGELOG.md and feed.xml 2026-06-09 22:14:36 +00:00
GitHub Actions
6a9c2dbe45 chore: Update CHANGELOG.md and feed.xml 2026-06-09 17:23:03 +00:00
GitHub Actions
f967b36c1b chore: Update CHANGELOG.md and feed.xml 2026-06-08 21:57:11 +00:00
GitHub Actions
72281753c2 chore: Update CHANGELOG.md and feed.xml 2026-06-06 23:41:47 +00:00
GitHub Actions
c1b75cba5e chore: Update CHANGELOG.md and feed.xml 2026-06-06 01:33:29 +00:00
GitHub Actions
6988846f0f chore: Update CHANGELOG.md and feed.xml 2026-06-06 00:55:13 +00:00
GitHub Actions
feabcc3c2b chore: Update CHANGELOG.md and feed.xml 2026-06-05 05:45:00 +00:00
GitHub Actions
d1e174252d chore: Update CHANGELOG.md and feed.xml 2026-06-04 21:52:46 +00:00
GitHub Actions
b67fa4fa2c chore: Update CHANGELOG.md and feed.xml 2026-06-03 21:31:29 +00:00
GitHub Actions
625c04c335 chore: Update CHANGELOG.md and feed.xml 2026-06-02 21:58:16 +00:00
GitHub Actions
bdb04fc524 chore: Update CHANGELOG.md and feed.xml 2026-06-02 02:10:17 +00:00
19 changed files with 3236 additions and 612 deletions

View File

@@ -72,7 +72,7 @@
{
"name": "frontend-design",
"description": "Create distinctive, production-grade frontend interfaces with high design quality. Generates creative, polished code that avoids generic AI aesthetics.",
"version": "1.0.0",
"version": "1.1.0",
"author": {
"name": "Prithvi Rajasekaran & Alexander Bricken",
"email": "prithvi@anthropic.com"

View File

@@ -22,71 +22,56 @@ jobs:
script: |
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const cutoff = sevenDaysAgo.toISOString().split('T')[0];
const lockComment = `This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.`;
let page = 1;
let hasMore = true;
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:closed is:unlocked updated:<${cutoff}`;
console.log(`Search query: ${query}`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const MAX_PER_RUN = 250;
const processed = new Set();
let totalLocked = 0;
while (hasMore) {
// Get closed issues (pagination)
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed',
while (totalLocked < MAX_PER_RUN) {
const { data } = await github.rest.search.issuesAndPullRequests({
q: query,
sort: 'updated',
direction: 'asc',
order: 'asc',
per_page: 100,
page: page
});
if (issues.length === 0) {
hasMore = false;
break;
if (totalLocked === 0) {
console.log(`Total candidates: ${data.total_count}`);
}
for (const issue of issues) {
// Skip if already locked
if (issue.locked) continue;
// Skip pull requests
if (issue.pull_request) continue;
// Check if updated more than 7 days ago
const updatedAt = new Date(issue.updated_at);
if (updatedAt > sevenDaysAgo) {
// Since issues are sorted by updated_at ascending,
// once we hit a recent issue, all remaining will be recent too
hasMore = false;
break;
}
const fresh = data.items.filter((i) => !processed.has(i.number));
if (fresh.length === 0) break;
for (const issue of fresh) {
if (totalLocked >= MAX_PER_RUN) break;
processed.add(issue.number);
try {
// Add comment before locking
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: lockComment
body: lockComment,
});
// Lock the issue
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
lock_reason: 'resolved'
lock_reason: 'resolved',
});
totalLocked++;
console.log(`Locked issue #${issue.number}: ${issue.title}`);
await sleep(1000);
} catch (error) {
console.error(`Failed to lock issue #${issue.number}: ${error.message}`);
}
}
page++;
}
console.log(`Total issues locked: ${totalLocked}`);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
# Keep the build context to just the binary the Dockerfile COPYs. BuildKit (the
# default, selected via the Dockerfile's syntax directive) only syncs the
# referenced COPY source anyway, so this is a no-op there — it matters for the
# classic builder (DOCKER_BUILDKIT=0) and as a conventional signal that the
# .gitignore'd secrets in this directory aren't part of the image build.
terraform/
**/.terraform/
*.tfstate*
terraform.tfvars
gateway.yaml
secrets/
*.pem
client_secret_*.json

12
examples/gateway/gcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Local, environment-specific config — copy gateway.yaml.example -> gateway.yaml
# (gateway.yaml.example IS committed; your filled-in gateway.yaml is not)
gateway.yaml
# Secrets / credentials — never commit
secrets/
client_secret_*.json
*.pem
# Release binary and pinned version — setup.sh downloads/writes these per release
claude
.claude-version

View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
# Runtime image for `claude gateway`.
#
# This image does NOT build the binary. It expects a prebuilt native
# linux-x64 `claude` executable in the build context — the public Claude Code
# release binary, which includes the `gateway` subcommand. setup.sh places it
# at ./claude (downloading it from the public release endpoint and verifying
# it against the release manifest if missing). Override CLAUDE_BINARY to
# point at a different path.
#
# Build (with the binary at ./claude; otherwise add --build-arg CLAUDE_BINARY=<path>):
# docker build --platform=linux/amd64 --provenance=false -t claude-gateway .
#
# Run:
# docker run --rm -p 8080:8080 \
# -v "$PWD/gateway.yaml:/etc/claude/gateway.yaml:ro" \
# -e OIDC_CLIENT_SECRET -e GATEWAY_JWT_SECRET -e GATEWAY_POSTGRES_URL \
# claude-gateway
ARG CLAUDE_BINARY=./claude
# distroless/cc provides glibc + libstdc++ (required by the Bun-compiled
# native binary). The :nonroot tag runs as uid/gid 65532.
FROM gcr.io/distroless/cc-debian12:nonroot
ARG CLAUDE_BINARY
COPY --chmod=0755 ${CLAUDE_BINARY} /usr/local/bin/claude
ENV CLAUDE_CONFIG_DIR=/tmp/.claude
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/usr/local/bin/claude", "gateway", "--config", "/etc/claude/gateway.yaml"]

View File

@@ -0,0 +1,17 @@
# Claude Gateway on Google Cloud
Reference deployment artifacts for running Claude Gateway on GCP with Agent
Platform (formerly Vertex AI) as the upstream: Cloud Run or GKE, Cloud SQL for
PostgreSQL, Secret Manager, and service-account auth to Agent Platform.
These files are provided as a working example rather than a supported production
deployment. Adapt them to your own environment.
- **Walkthrough**: https://code.claude.com/docs/en/claude-apps-gateway-on-gcp
| File | Purpose |
|---|---|
| `setup.sh` | Scripts the walkthrough end to end via `gcloud` |
| `Dockerfile` | Runtime image for the `claude gateway` binary |
| `gateway.yaml.example` | Gateway config template, GCP-shaped (Agent Platform upstream, Google Workspace IdP) |
| `terraform/` | Provisions the full architecture (two-pass apply — see `terraform/README.md`) |

View File

@@ -0,0 +1,156 @@
# gateway.yaml.example — Claude Gateway config template, GCP-shaped (walkthrough §6).
#
# Google Workspace IdP + Agent Platform (formerly Vertex AI) upstream, following
# the walkthrough at https://code.claude.com/docs/en/claude-apps-gateway-on-gcp.
# The active sections
# below are a strict subset of the full configuration reference at
# https://code.claude.com/docs/en/claude-apps-gateway; optional keys are included
# commented-out.
#
# USAGE — this is the shippable TEMPLATE. Copy it to gateway.yaml and fill it in:
# cp gateway.yaml.example gateway.yaml
# setup.sh and terraform/ read gateway.yaml (your filled-in copy, which is
# gitignored). It is published as the Secret Manager secret `gateway-config`
# (§6) and mounted at /etc/claude/gateway.yaml — the container ENTRYPOINT runs
# `claude gateway --config /etc/claude/gateway.yaml`.
#
# Secret expansion: ${ENV_VAR} reads an env var; ${file:/path} reads a mounted file.
# On Cloud Run, setup.sh injects the JWT / OIDC / Postgres secrets as ENV VARS
# (Cloud Run can't mount multiple secrets into a single directory), and mounts
# only gateway.yaml itself as a file at /etc/claude. On GKE you may use file mounts.
#
# BEFORE DEPLOY — replace every REPLACE_ME placeholder below (setup.sh refuses to
# publish the config secret while any remain), and create the referenced secrets:
# gateway-jwt-secret (setup.sh generates this)
# gateway-oidc-client-secret (from the Google Cloud Console OAuth client)
# gateway-postgres-url (setup.sh generates this)
# ── Listener ─────────────────────────────────────────────────────────────────
listen:
host: 0.0.0.0
port: 8080 # Cloud Run sets PORT=8080; leave as-is
# Required. Fixes the IdP redirect_uri, the OIDC discovery doc, and the
# gateway-token issuer so none are derived from the client-controlled Host
# header (X-Forwarded-Host/-Proto are likewise never trusted). On Cloud Run
# the run.app URL is only assigned on the first deploy, so this starts as a
# placeholder for the provisioning-only first pass (login does NOT work until
# the real URL is set). After the first deploy, setup.sh prints the run.app
# URL: set it here (or your LB hostname) and re-run; setup.sh republishes the
# config and redeploys. Register the same host's /oauth/callback on the
# Google OAuth client.
public_url: https://set-after-first-deploy.invalid
# Register this exact redirect URI on the Google OAuth client:
# https://<public_url host>/oauth/callback
#
# On Cloud Run (or behind any L7 LB) every request arrives via Google's front
# end, so the gateway sees one peer IP for all developers — set trusted_proxies
# so X-Forwarded-For from those proxies is trusted and per-IP rate limiting /
# audit IPs record the real client. 169.254.0.0/16 is Cloud Run's fixed
# link-local serving range; the proxy-only subnet is the one your internal ALB
# uses in this VPC.
# trusted_proxies:
# - 169.254.0.0/16 # Cloud Run serving proxy (link-local peer)
# - <proxy-only-subnet-cidr> # add if fronted by your internal ALB (its proxy-only subnet)
#
# Alternative — terminate TLS in the gateway itself instead of at a proxy:
# tls:
# cert: /certs/gateway.crt
# key: /certs/gateway.key
# ── Identity provider — Google Workspace ─────────────────────────────────────
oidc:
issuer: https://accounts.google.com
client_id: REPLACE_ME # Google OAuth client ID (not secret; from Cloud Console)
client_secret: ${OIDC_CLIENT_SECRET}
allowed_email_domains: [REPLACE_ME] # e.g. [example.com] — reject id_tokens outside your org
# Google ignores the default offline_access scope; these two are what actually
# yield refresh tokens (silent renewal + the deprovision leash) from Google.
scopes: [openid, profile, email]
extra_auth_params: { access_type: offline, prompt: consent }
# NOTE: Google id_tokens carry NO groups claim. For group-based RBAC with
# Google as IdP, set `google_groups` (below) and the gateway fetches each
# user's Workspace groups at login via the Admin SDK Directory API.
# Otherwise, use email_domain matching (see managed.policies below).
# google_groups:
# service_account_json_path: /secrets/google-sa.json # SA with domain-wide delegation on admin.directory.group.readonly
# admin_email: admin@example.com # a Workspace admin the SA impersonates
# groups_claim: groups # Okta=groups, Entra app roles=roles — NOT Google
# ca_cert_pem: ${file:/secrets/idp-ca.pem} # only for an IdP behind a private CA
# ── Sessions ─────────────────────────────────────────────────────────────────
session:
jwt_secret: ${GATEWAY_JWT_SECRET} # >= 32 bytes; openssl rand -base64 32
# Google issues refresh tokens (above), so sessions renew silently and this
# mainly bounds deprovision latency. 8 is a sane default; lower toward 1 for
# tighter revocation. Array form rotates keys: [new, old] (index 0 signs, all verify).
ttl_hours: 8
# ── Store (REQUIRED — the gateway refuses to boot without it) ─────────────────
store:
postgres_url: ${GATEWAY_POSTGRES_URL} # private-IP Cloud SQL; built with ?sslmode=require by setup.sh
# ── Upstreams — Agent Platform ───────────────────────────────────────────────
upstreams:
- provider: vertex
region: us-east5 # a region where the Claude models you need are published in Model Garden
project_id: REPLACE_ME # your GCP project ID for Agent Platform access
auth: {} # ADC via Cloud Run SA / GKE Workload Identity (preferred — no static keys)
# base_url: https://us-east5-aiplatform.p.googleapis.com # Private Service Connect endpoint
# Add more upstreams for failover (tried top→bottom on 5xx/timeout/501): a
# second region, or an anthropic/bedrock fallback. See
# https://code.claude.com/docs/en/claude-apps-gateway.
# ── Telemetry fan-out (OPTIONAL) ─────────────────────────────────────────────
# The CLI sends OTLP/HTTP to the gateway; the gateway fans out, stamping
# user.id/user.email/user.groups server-side. On GCP, point at an OpenTelemetry
# Collector with the googlecloud exporter (-> Cloud Trace / Managed Prometheus).
# Takes effect after the second pass (once public_url is the real URL, not the
# placeholder): when forward_to and public_url are both configured the gateway pushes
# CLAUDE_CODE_ENABLE_TELEMETRY and the OTEL exporter selectors to every client
# automatically — no per-developer config needed.
# telemetry:
# forward_to:
# - url: https://otel-collector.internal.example.com:4318
# headers:
# Authorization: ${file:/secrets/otlp-token}
# metrics: true # safe aggregate counters (default)
# logs: false # carries bash commands / tool inputs — opt in deliberately
# traces: false
# ── RBAC + managed settings (OPTIONAL; first-match-wins, top -> bottom) ───────
# With Google as IdP, match on email_domain, or on group email addresses
# (e.g. eng@example.com) once oidc.google_groups is configured above.
# managed:
# policies:
# - match: { email_domain: example.com }
# cli:
# availableModels: [claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5]
# permissions: { deny: ["Read(./.env)", "Read(./secrets/**)"] }
# - match: {} # catch-all floor — keep LAST
# cli:
# availableModels: [claude-sonnet-4-6, claude-haiku-4-5]
# ── Admin API (OPTIONAL — enables db-mode runtime config + spend caps) ───────
# admin_groups needs a groups claim — with Google as IdP, set
# oidc.google_groups (above) so Workspace group email addresses populate the
# claim, or use the bootstrap keys below instead. Named keys for
# attribution in the audit log; 32-char minimum on key values. On Cloud Run add
# these as env vars to --set-secrets (or terraform env value_source blocks),
# same as the JWT/OIDC/Postgres secrets above; on GKE you may use ${file:...}.
# admin:
# write_keys:
# - id: terraform
# key: ${GATEWAY_ADMIN_WRITE_KEY}
# read_keys:
# - id: reporting
# key: ${GATEWAY_ADMIN_READ_KEY}
# # admin_groups: [platform-finops@example.com] # group emails via oidc.google_groups, or any groups-capable IdP
# ── Model catalog (OPTIONAL) ─────────────────────────────────────────────────
# Default true: every built-in Claude model is exposed and auto-translated per
# upstream. Set false + a models: list to pin IDs (e.g. provisioned throughput).
# auto_include_builtin_models: true
# models:
# - id: claude-opus-4-8
# label: Claude Opus 4.8
# upstream_model: { vertex: claude-opus-4-8 }

558
examples/gateway/gcp/setup.sh Executable file
View File

@@ -0,0 +1,558 @@
#!/usr/bin/env bash
#
# setup.sh — GCP setup for Claude Gateway (walkthrough §17b).
#
# Provisions, in doc order: APIs (§1), service account + IAM (§2), the gateway
# container image in Artifact Registry (§3), a Cloud SQL (PostgreSQL) backend
# with PRIVATE IP only (§4), the JWT + postgres-url secrets (§5), the
# gateway.yaml config secret (§6), and a Cloud Run deploy with Direct VPC
# egress (§7b).
#
# Private IP is required because public IP is disallowed by the org-policy constraint
# `constraints/sql.restrictPublicIp`. A Cloud SQL private IP is an address inside a VPC,
# so §4 here also provisions the prerequisite VPC + Private Services Access — the
# one-time, irreducible networking required for private IP.
#
# Section markers (§N) below map to the walkthrough:
# https://code.claude.com/docs/en/claude-apps-gateway-on-gcp
#
# Covers here: APIs (§1) -> service account + IAM (§2) -> build & push image (§3)
# -> VPC + Private Services Access -> Cloud SQL (private IP only) -> database
# + user (§4) -> jwt + postgres-url secrets (§5) -> gateway-config
# secret from gateway.yaml (§6) -> Cloud Run deploy (§7b).
# Not covered: GKE track (§7a) — Cloud Run is the lower-friction path here.
#
# Idempotent: existing resources are detected and skipped, so it is safe to re-run.
# Override any default below via environment variable, e.g. `REGION=us-east5 ./setup.sh`.
set -euo pipefail
# ---- configuration (env-overridable) ----------------------------------------
PROJECT_ID="${PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}"
REGION="${REGION:-${CLOUDSDK_COMPUTE_REGION:-us-east5}}" # guide §1 uses us-east5 (Agent Platform model region)
SA_NAME="${SA_NAME:-claude-gateway}" # §2 service account
SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
# §3 image
AR_REPO="${AR_REPO:-claude-gateway}" # Artifact Registry repository
IMAGE_NAME="${IMAGE_NAME:-gateway}"
RELEASES_URL="${RELEASES_URL:-https://downloads.claude.ai/claude-code-releases}" # public Claude Code release endpoint
VERSION="${VERSION:-}" # Claude Code release to deploy; empty = latest release (resolved below)
VERSION_FILE="${VERSION_FILE:-./.claude-version}" # pins the resolved release across re-runs; delete it (or set VERSION) to upgrade
DOCKERFILE="${DOCKERFILE:-./Dockerfile}"
CLAUDE_BINARY="${CLAUDE_BINARY:-./claude}" # linux-x64 Claude Code binary; downloaded from RELEASES_URL if missing
CLAUDE_SHA256="${CLAUDE_SHA256:-}" # optional: out-of-band sha256 pin for the downloaded binary, checked in addition to the release manifest
VPC_NETWORK="${VPC_NETWORK:-cc-gateway-vpc}"
SUBNET="${SUBNET:-cc-gateway-subnet}"
SUBNET_RANGE="${SUBNET_RANGE:-10.0.0.0/24}"
PSA_RANGE_NAME="${PSA_RANGE_NAME:-google-managed-services-${VPC_NETWORK}}"
PSA_PREFIX_LENGTH="${PSA_PREFIX_LENGTH:-16}" # /16 is GCP's recommendation; reserved, not consumed
DB_INSTANCE="${DB_INSTANCE:-claude-gateway-db}"
DB_VERSION="${DB_VERSION:-POSTGRES_16}" # PG14+ supported; 16 is the recommended default (§4)
DB_TIER="${DB_TIER:-db-g1-small}"
DB_NAME="${DB_NAME:-claude_gateway}"
DB_USER="${DB_USER:-gateway}"
SECRET_NAME="${SECRET_NAME:-gateway-postgres-url}" # §5 store.postgres_url
JWT_SECRET_NAME="${JWT_SECRET_NAME:-gateway-jwt-secret}" # §5 session.jwt_secret
GATEWAY_YAML="${GATEWAY_YAML:-./gateway.yaml}" # §6 config file
CONFIG_SECRET="${CONFIG_SECRET:-gateway-config}" # §6 mounted at /etc/claude/gateway.yaml
# §7 Cloud Run deploy
SERVICE_NAME="${SERVICE_NAME:-claude-gateway}"
OIDC_SECRET_NAME="${OIDC_SECRET_NAME:-gateway-oidc-client-secret}" # operator-created (Google OAuth client)
DEPLOY="${DEPLOY:-1}" # set DEPLOY=0 to provision only, no Cloud Run deploy
INGRESS="${INGRESS:-internal}" # internal (default; no public URL) | internal-and-cloud-load-balancing (only if you front it with your own internal ALB)
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
# ---- helpers ----------------------------------------------------------------
log() { printf '\n==> %s\n' "$*"; }
skip() { printf ' (exists) %s\n' "$*"; }
curl_https() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 "$@"; } # refuse plaintext/protocol-downgrade
sha_of() { openssl dgst -sha256 "$1" | awk '{print $NF}'; } # openssl avoids shasum/sha256sum portability gaps
if [[ -z "${PROJECT_ID}" ]]; then
echo "ERROR: PROJECT_ID is not set and no gcloud default project is configured." >&2
echo " Set it with: export PROJECT_ID=<your-project> (or 'gcloud config set project ...')" >&2
exit 1
fi
# VERSION tags the image and selects the public Claude Code release to download.
# The first resolved value is pinned to ${VERSION_FILE} so the documented
# re-runs (fill gateway.yaml -> re-run; set public_url -> re-run) don't silently
# build and deploy a newer release mid-bootstrap.
if [[ -z "${VERSION}" && -f "${VERSION_FILE}" ]]; then
VERSION="$(< "${VERSION_FILE}")"
log "Using release pinned in ${VERSION_FILE}: ${VERSION} (delete the file or set VERSION to change it)"
elif [[ -z "${VERSION}" ]]; then
# /latest is the channel the official installer (claude.ai/install.sh) uses.
VERSION="$(curl_https -fsSL "${RELEASES_URL}/latest" | tr -d '[:space:]' || true)"
if [[ -z "${VERSION}" ]]; then
echo "ERROR: could not resolve the latest release from ${RELEASES_URL}/latest." >&2
echo " Set VERSION to a Claude Code release version, e.g. export VERSION=2.1.195" >&2
exit 1
fi
log "VERSION not set — using latest Claude Code release: ${VERSION}"
fi
# Reject non-version content (e.g. an HTML error page served with HTTP 200)
# before it reaches the image tag and download URLs.
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "ERROR: '${VERSION}' is not a release version (from VERSION, ${VERSION_FILE}, or ${RELEASES_URL}/latest)." >&2
exit 1
fi
printf '%s' "${VERSION}" > "${VERSION_FILE}"
IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${AR_REPO}/${IMAGE_NAME}:${VERSION}"
# Claude Code only connects to a gateway whose hostname resolves to private
# addresses (a client-side /login check), so public ingress can never serve
# clients — mirror the terraform module's validation and refuse it up front.
if [[ "${INGRESS}" != "internal" && "${INGRESS}" != "internal-and-cloud-load-balancing" ]]; then
echo "ERROR: INGRESS must be 'internal' or 'internal-and-cloud-load-balancing' — Claude Code's" >&2
echo " /login only accepts gateway hosts on private addresses, so public ingress cannot serve clients." >&2
exit 1
fi
log "Project: ${PROJECT_ID} Region: ${REGION} VPC: ${VPC_NETWORK}"
# ---- 1 Project & API setup ------------------------------------------------
# walkthrough §1 list (aiplatform, artifactregistry, sqladmin, secretmanager, iamcredentials)
# plus iam/compute/servicenetworking required for the SA + private-IP networking below.
# container.googleapis.com is for the GKE track (§7a) — harmless if you stay on Cloud Run.
# We pass --project on every call rather than mutating your gcloud config.
log "Enabling required APIs (§1)"
gcloud services enable \
aiplatform.googleapis.com \
artifactregistry.googleapis.com \
sqladmin.googleapis.com \
secretmanager.googleapis.com \
iamcredentials.googleapis.com \
iam.googleapis.com \
compute.googleapis.com \
container.googleapis.com \
servicenetworking.googleapis.com \
run.googleapis.com \
--project="${PROJECT_ID}"
# ---- 2 Service account & IAM ----------------------------------------------
log "Creating service account ${SA_EMAIL} and granting project roles (§2)"
if gcloud iam service-accounts describe "${SA_EMAIL}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
skip "service account ${SA_EMAIL}"
else
gcloud iam service-accounts create "${SA_NAME}" \
--display-name="Claude Gateway" --project="${PROJECT_ID}"
fi
# add-iam-policy-binding is idempotent (re-adding an existing binding is a no-op).
# --condition=None avoids the interactive condition prompt in non-interactive runs.
#
# Only aiplatform.user is granted: the gateway reaches Cloud SQL over the VPC at
# its PRIVATE IP with a password user (§4/§7b — direct TCP, not the Cloud SQL
# Auth Proxy / connector), so it never calls cloudsql.instances.connect and no
# roles/cloudsql.client grant is needed. Direct private-IP is used because the
# gateway's store is a plain postgres_url — no proxy sidecar/socket plumbing,
# one less moving part, and the connection string is portable across Cloud Run
# and GKE.
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/aiplatform.user" --condition=None >/dev/null # Agent Platform inference (§2)
# ---- 3 Build & push image to Artifact Registry ----------------------------
log "Ensuring Artifact Registry repo and image (§3)"
if gcloud artifacts repositories describe "${AR_REPO}" \
--location="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
skip "Artifact Registry repo ${AR_REPO}"
else
gcloud artifacts repositories create "${AR_REPO}" \
--repository-format=docker --location="${REGION}" --project="${PROJECT_ID}"
fi
# Image is the expensive, already-done step: skip the build+push entirely if the
# tag already exists in the registry.
if gcloud artifacts docker images describe "${IMAGE}" >/dev/null 2>&1; then
skip "image ${IMAGE}"
else
# The public Claude Code release includes the gateway subcommand, so the
# binary comes straight from the release endpoint, verified against the
# release manifest's sha256. A pre-existing ${CLAUDE_BINARY} (stale version,
# interrupted download, hand-placed file) is verified the same way and
# re-downloaded on mismatch, so an unverified binary can never reach the image.
manifest="$(curl_https -fsSL "${RELEASES_URL}/${VERSION}/manifest.json" | tr -d '[:space:]' || true)"
sha_re='"linux-x64"[^}]*"checksum":"([a-f0-9]{64})"' # structure-based: survives pretty-printed, minified, and one-line-per-platform manifests
if [[ ! "${manifest}" =~ ${sha_re} ]]; then
echo "ERROR: could not read the linux-x64 sha256 from ${RELEASES_URL}/${VERSION}/manifest.json — refusing to build." >&2
exit 1
fi
expected_sha="${BASH_REMATCH[1]}"
if [[ -f "${CLAUDE_BINARY}" && "$(sha_of "${CLAUDE_BINARY}")" == "${expected_sha}" ]]; then
skip "binary ${CLAUDE_BINARY} (sha256 matches release ${VERSION})"
else
if [[ -f "${CLAUDE_BINARY}" ]]; then
log "Existing ${CLAUDE_BINARY} does not match release ${VERSION} — re-downloading"
else
log "Downloading Claude Code ${VERSION} (linux-x64) from ${RELEASES_URL}"
fi
# Until verification passes, ANY exit (curl failure, set -e, signal, the
# error exit below) removes the file, so a partial download can't be
# silently picked up by a later run.
trap 'rm -f "${CLAUDE_BINARY}"' EXIT INT TERM
curl_https -fL -o "${CLAUDE_BINARY}" "${RELEASES_URL}/${VERSION}/linux-x64/claude"
actual_sha="$(sha_of "${CLAUDE_BINARY}")"
if [[ "${actual_sha}" != "${expected_sha}" ]]; then
echo "ERROR: sha256 of ${CLAUDE_BINARY} is ${actual_sha} but the release manifest says ${expected_sha} — refusing to build." >&2
exit 1
fi
trap - EXIT INT TERM
log "Verified binary sha256 ${actual_sha}"
fi
# Optional out-of-band pin, checked even for a pre-existing binary: the
# manifest shares an origin with the binary, so it can't defend against a
# compromised endpoint — CLAUDE_SHA256 can.
if [[ -n "${CLAUDE_SHA256}" && "$(sha_of "${CLAUDE_BINARY}")" != "${CLAUDE_SHA256}" ]]; then
echo "ERROR: sha256 of ${CLAUDE_BINARY} does not match CLAUDE_SHA256 (${CLAUDE_SHA256}) — refusing to build." >&2
exit 1
fi
chmod +x "${CLAUDE_BINARY}"
log "Building and pushing ${IMAGE}"
gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
# Cloud Run requires linux/amd64. --platform forces it (e.g. when building on an
# Apple Silicon Mac), and --provenance=false keeps buildx from wrapping the result
# in an OCI image index that Cloud Run rejects ("manifest ... must support amd64/linux").
docker build --platform=linux/amd64 --provenance=false \
-f "${DOCKERFILE}" --build-arg CLAUDE_BINARY="${CLAUDE_BINARY}" -t "${IMAGE}" .
docker push "${IMAGE}"
fi
# ---- 4 VPC + Private Services Access (private-IP prerequisite) -------------
log "Creating VPC network and subnet"
if gcloud compute networks describe "${VPC_NETWORK}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
skip "network ${VPC_NETWORK}"
else
gcloud compute networks create "${VPC_NETWORK}" \
--subnet-mode=custom --project="${PROJECT_ID}"
fi
if gcloud compute networks subnets describe "${SUBNET}" \
--region="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
skip "subnet ${SUBNET}"
else
gcloud compute networks subnets create "${SUBNET}" \
--network="${VPC_NETWORK}" --region="${REGION}" \
--range="${SUBNET_RANGE}" --project="${PROJECT_ID}"
fi
log "Configuring Private Services Access (allocated range + VPC peering)"
if gcloud compute addresses describe "${PSA_RANGE_NAME}" \
--global --project="${PROJECT_ID}" >/dev/null 2>&1; then
skip "allocated range ${PSA_RANGE_NAME}"
else
gcloud compute addresses create "${PSA_RANGE_NAME}" \
--global --purpose=VPC_PEERING --prefix-length="${PSA_PREFIX_LENGTH}" \
--network="${VPC_NETWORK}" --project="${PROJECT_ID}"
fi
if gcloud services vpc-peerings list --network="${VPC_NETWORK}" --project="${PROJECT_ID}" \
--format='value(peering)' 2>/dev/null | grep -q servicenetworking; then
skip "servicenetworking VPC peering"
else
gcloud services vpc-peerings connect \
--service=servicenetworking.googleapis.com \
--ranges="${PSA_RANGE_NAME}" \
--network="${VPC_NETWORK}" --project="${PROJECT_ID}"
fi
# ---- 4 Cloud SQL instance (private IP only) -------------------------------
log "Creating Cloud SQL instance ${DB_INSTANCE} (private IP only)"
if gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
skip "instance ${DB_INSTANCE}"
else
gcloud sql instances create "${DB_INSTANCE}" \
--database-version="${DB_VERSION}" \
--tier="${DB_TIER}" \
--region="${REGION}" \
--network="projects/${PROJECT_ID}/global/networks/${VPC_NETWORK}" \
--no-assign-ip \
--project="${PROJECT_ID}"
fi
log "Creating database ${DB_NAME}"
if gcloud sql databases describe "${DB_NAME}" \
--instance="${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
skip "database ${DB_NAME}"
else
gcloud sql databases create "${DB_NAME}" \
--instance="${DB_INSTANCE}" --project="${PROJECT_ID}"
fi
# hex (not base64) keeps the password URL-safe for the connection string below.
log "Creating database user ${DB_USER}"
DB_PASSWORD=""
if gcloud sql users list --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" \
--format='value(name)' 2>/dev/null | grep -qx "${DB_USER}"; then
if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
skip "user ${DB_USER} (password unchanged; secret not rewritten)"
else
# Self-heal: a previous run died after creating the user but before writing
# the connection-string secret, losing the only copy of the password. The
# secret is the password's only consumer, so resetting it is safe and keeps
# re-runs able to recover from any partial state.
log "User ${DB_USER} exists but secret ${SECRET_NAME} is missing — resetting password"
DB_PASSWORD="$(openssl rand -hex 24)"
gcloud sql users set-password "${DB_USER}" \
--instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \
--project="${PROJECT_ID}"
fi
else
DB_PASSWORD="$(openssl rand -hex 24)"
gcloud sql users create "${DB_USER}" \
--instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \
--project="${PROJECT_ID}"
fi
# ---- 5 Connection string -> Secret Manager + secretAccessor ---------------
PRIVATE_IP="$(gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" \
--format='value(ipAddresses[0].ipAddress)')"
if [[ -n "${DB_PASSWORD}" ]]; then
# direct private-IP form, ?sslmode=require (guide §4)
CONN="postgres://${DB_USER}:${DB_PASSWORD}@${PRIVATE_IP}:5432/${DB_NAME}?sslmode=require"
log "Storing connection string in Secret Manager secret ${SECRET_NAME}"
if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
printf '%s' "${CONN}" | gcloud secrets versions add "${SECRET_NAME}" \
--data-file=- --project="${PROJECT_ID}"
else
printf '%s' "${CONN}" | gcloud secrets create "${SECRET_NAME}" \
--replication-policy=automatic --data-file=- --project="${PROJECT_ID}"
fi
else
log "Skipping secret write (user already existed, password not available this run)"
fi
if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
log "Granting ${SA_EMAIL} secretAccessor on ${SECRET_NAME}"
gcloud secrets add-iam-policy-binding "${SECRET_NAME}" \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/secretmanager.secretAccessor" \
--condition=None --project="${PROJECT_ID}" >/dev/null
fi
# JWT signing secret — generated once (re-runs do NOT rotate it).
log "Ensuring JWT signing secret ${JWT_SECRET_NAME} (§5)"
if gcloud secrets describe "${JWT_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
skip "secret ${JWT_SECRET_NAME}"
else
openssl rand -base64 32 | tr -d '\n' | gcloud secrets create "${JWT_SECRET_NAME}" \
--replication-policy=automatic --data-file=- --project="${PROJECT_ID}"
fi
gcloud secrets add-iam-policy-binding "${JWT_SECRET_NAME}" \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/secretmanager.secretAccessor" \
--condition=None --project="${PROJECT_ID}" >/dev/null
# OIDC client secret — operator-created (the script can't generate it). Grant
# accessor here once it exists so the deploy step doesn't fail on permission.
if gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
log "Granting ${SA_EMAIL} secretAccessor on ${OIDC_SECRET_NAME}"
gcloud secrets add-iam-policy-binding "${OIDC_SECRET_NAME}" \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/secretmanager.secretAccessor" \
--condition=None --project="${PROJECT_ID}" >/dev/null
fi
# ---- 6 gateway.yaml -> Secret Manager (gateway-config) --------------------
# Published only when fully filled in: refuse to push a config that still has
# REPLACE_ME placeholders (checked on non-comment lines so commented examples
# and this file's header don't trip the guard).
log "Publishing ${GATEWAY_YAML} as Secret Manager secret ${CONFIG_SECRET} (§6)"
if [[ ! -f "${GATEWAY_YAML}" ]]; then
echo " (skip) ${GATEWAY_YAML} not found — run 'cp gateway.yaml.example gateway.yaml', fill it in, then re-run (§6)."
elif grep -vE '^[[:space:]]*#' "${GATEWAY_YAML}" | grep -q 'REPLACE_ME'; then
echo " (skip) ${GATEWAY_YAML} still has REPLACE_ME placeholders to fill:"
grep -nE 'REPLACE_ME' "${GATEWAY_YAML}" | grep -vE '^[0-9]+:[[:space:]]*#' | sed 's/^/ /'
echo " Fill them in, then re-run to publish ${CONFIG_SECRET}."
else
if gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
gcloud secrets versions add "${CONFIG_SECRET}" \
--data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}"
else
gcloud secrets create "${CONFIG_SECRET}" --replication-policy=automatic \
--data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}"
fi
gcloud secrets add-iam-policy-binding "${CONFIG_SECRET}" \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/secretmanager.secretAccessor" \
--condition=None --project="${PROJECT_ID}" >/dev/null
fi
# ---- 7 Cloud Run deploy (Direct VPC egress) -------------------------------
# Direct VPC egress (--network/--subnet/--vpc-egress) puts the service on the
# VPC so it reaches the Cloud SQL PRIVATE IP directly — matching the private-IP
# connection string in the postgres-url secret. private-ranges-only keeps public
# egress (Agent Platform, accounts.google.com) off the VPC, so no Cloud NAT is needed.
# We deliberately do NOT use --add-cloudsql-instances (that's the Auth Proxy /
# socket path, which would need a different connection string).
#
# Secrets: gateway.yaml is mounted as a FILE at /etc/claude (alone in its dir).
# The JWT / OIDC / Postgres secrets are injected as ENV VARS — Cloud Run cannot
# mount multiple secrets into one directory, and gateway.yaml references them via
# ${ENV_VAR}. (See the env-var names in gateway.yaml: GATEWAY_JWT_SECRET etc.)
#
# Self-gating: deploy only once its inputs exist (config secret published + the
# operator-provided OIDC client secret). On a first run these are missing and it
# cleanly skips.
RUN_URL=""
missing=""
gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${CONFIG_SECRET}"
gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${OIDC_SECRET_NAME}"
# Also gate on the postgres-url secret (referenced by --set-secrets below): if it
# is somehow absent, skip with a clear message rather than failing the deploy with
# a raw Cloud Run missing-secret error.
gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${SECRET_NAME}"
if [[ "${DEPLOY}" != "1" ]]; then
log "Skipping Cloud Run deploy (DEPLOY=${DEPLOY}) (§7)"
elif [[ -n "${missing// }" ]]; then
log "Skipping Cloud Run deploy — missing secret(s):${missing} (§7)"
echo " Fill ${GATEWAY_YAML} and re-run to publish ${CONFIG_SECRET}; create ${OIDC_SECRET_NAME}"
echo " from the Google OAuth client. Then re-run to deploy."
else
SECRET_MOUNTS="/etc/claude/gateway.yaml=${CONFIG_SECRET}:latest" # file mount (alone in /etc/claude)
SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_JWT_SECRET=${JWT_SECRET_NAME}:latest" # env var
SECRET_MOUNTS="${SECRET_MOUNTS},OIDC_CLIENT_SECRET=${OIDC_SECRET_NAME}:latest" # env var
SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_POSTGRES_URL=${SECRET_NAME}:latest" # env var
log "Deploying Cloud Run service ${SERVICE_NAME} (§7b, Direct VPC egress)"
# Deploy private (--no-allow-unauthenticated avoids the interactive prompt and
# keeps allUsers OUT of the deploy, so a Domain-Restricted-Sharing org doesn't
# fail the deploy on the IAM step). Public access is attempted separately below.
#
# --ingress is passed EXPLICITLY because it is sticky across redeploys (omitting
# it keeps the previous value). The default, internal, keeps the *.run.app URL
# off the public internet — reachable only from this VPC, or from corp networks
# with the PSC endpoint + private run.app DNS plumbing (see terraform/README.md
# "Private access"). Public ingress cannot serve clients (see the INGRESS
# guard at the top of this script), so the two-pass OAuth bootstrap has to be
# completed from inside the VPC (or a PSC-connected corp network). Use
# internal-and-cloud-load-balancing instead if you front the service with
# your own internal ALB.
#
# --timeout=3600 raises Cloud Run's default 300s request timeout, which would
# otherwise cut off long streaming /v1/messages responses mid-stream.
#
# --max-instances bounds the Postgres connection footprint: each instance
# opens a pool of up to 5 connections (store.max_connections default) and
# db-g1-small caps at ~50 max_connections, so the default ceiling of 100
# instances would crash-loop new instances under load. Keep
# max-instances × 5 below the DB tier's max_connections; raise the DB tier
# (or set store.max_connections lower) before raising this.
gcloud run deploy "${SERVICE_NAME}" \
--image="${IMAGE}" \
--region="${REGION}" \
--service-account="${SA_EMAIL}" \
--min-instances=1 \
--max-instances="${MAX_INSTANCES}" \
--port=8080 \
--timeout=3600 \
--ingress="${INGRESS}" \
--network="${VPC_NETWORK}" \
--subnet="${SUBNET}" \
--vpc-egress=private-ranges-only \
--set-secrets="${SECRET_MOUNTS}" \
--no-allow-unauthenticated \
--project="${PROJECT_ID}"
# The gateway runs its OWN OIDC, so the Cloud Run IAM layer must allow
# unauthenticated. Attempt it separately and tolerate failure: Domain Restricted
# Sharing (iam.allowedPolicyMemberDomains) blocks allUsers in hardened orgs.
log "Granting public invoker (allUsers) — required for the gateway's OIDC login"
if gcloud run services add-iam-policy-binding "${SERVICE_NAME}" \
--region="${REGION}" --member=allUsers --role=roles/run.invoker \
--project="${PROJECT_ID}" >/dev/null 2>&1; then
echo " public invoker granted."
else
echo " WARN: allUsers rejected (likely Domain Restricted Sharing). The service is"
echo " deployed but the invoker IAM check is still enabled, so requests 403"
echo " before reaching the container. Preferred fix (where available):"
echo " gcloud run services update ${SERVICE_NAME} --no-invoker-iam-check \\"
echo " --region=${REGION} --project=${PROJECT_ID}"
echo " Alternatively: request a DRS exception for ${SERVICE_NAME}, or use the GKE"
echo " track, which exposes the gateway at the network layer with no allUsers"
echo " binding. An LB is NOT a fix — it does not bypass the invoker IAM check."
fi
RUN_URL="$(gcloud run services describe "${SERVICE_NAME}" --region="${REGION}" \
--project="${PROJECT_ID}" --format='value(status.url)')"
log "Cloud Run URL: ${RUN_URL}"
# public_url is now required (config validation refuses a non-loopback bind
# without it), so the template ships a placeholder for the first pass. Once we
# know the real URL, warn on any mismatch so the operator doesn't leave the
# placeholder — or a stale hostname — in place. Normalize quotes / inline
# comments / a trailing slash so schema-equivalent spellings compare equal.
# Only checked with internal ingress, where public_url should be the run.app
# URL; behind an internal ALB it is the ALB hostname, which this script
# cannot know.
CFG_PUBLIC_URL="$(grep -E '^[[:space:]]*public_url:' "${GATEWAY_YAML}" 2>/dev/null \
| head -1 \
| sed -E 's/^[[:space:]]*public_url:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]*$//' \
|| true)"
CFG_PUBLIC_URL="${CFG_PUBLIC_URL#[\'\"]}"; CFG_PUBLIC_URL="${CFG_PUBLIC_URL%[\'\"]}"
CFG_PUBLIC_URL="${CFG_PUBLIC_URL%/}"
if [[ "${INGRESS}" == "internal" && -n "${RUN_URL}" && "${CFG_PUBLIC_URL}" != "${RUN_URL%/}" ]]; then
echo " NOTE — ${GATEWAY_YAML} has public_url: ${CFG_PUBLIC_URL:-<unset>}"
echo " but this service's URL is ${RUN_URL}."
echo " Set listen.public_url to ${RUN_URL} (or your LB hostname) and re-run."
fi
if [[ -n "${RUN_URL}" ]]; then
# gcloud run deploy already fails the script if the revision can't boot (it
# waits for the Ready condition), so what's left to verify is that the
# gateway is serving. The OAuth discovery document below returns 200 only
# after config load, OIDC discovery, upstream construction, and Postgres
# migration all succeed, so it doubles as an end-to-end boot check (the
# readiness probe proper is GET /readyz). With internal ingress the URL is
# reachable only from inside the VPC (or a PSC-connected corp network), so
# verification is left to the operator rather than attempted from here.
log "Verify the gateway is serving (from inside the VPC, or a PSC-connected corp network):"
echo " curl -s ${RUN_URL}/.well-known/oauth-authorization-server"
echo " If it isn't responding yet, check logs:"
echo " gcloud run services logs read ${SERVICE_NAME} --region=${REGION} --project=${PROJECT_ID}"
log "Finish the OAuth bootstrap:"
echo " 1. Register this redirect URI on the Google OAuth client: ${RUN_URL}/oauth/callback"
echo " 2. Set listen.public_url in ${GATEWAY_YAML} to ${RUN_URL}, then re-run: INGRESS=${INGRESS} ./setup.sh"
echo " (republishes ${CONFIG_SECRET} and redeploys so the IdP redirect_uri matches)."
echo " With INGRESS=internal-and-cloud-load-balancing, use your internal ALB hostname"
echo " instead of the run.app URL in both steps."
fi
fi
# ---- summary ----------------------------------------------------------------
cat <<EOF
==> Done.
Service account ${SA_EMAIL}
roles: aiplatform.user, secretmanager.secretAccessor
Image ${IMAGE}
Instance ${DB_INSTANCE}
Connection name ${PROJECT_ID}:${REGION}:${DB_INSTANCE}
Private IP ${PRIVATE_IP}
Database / user ${DB_NAME} / ${DB_USER}
Secrets ${SECRET_NAME}, ${JWT_SECRET_NAME}, ${CONFIG_SECRET}
Cloud Run service ${SERVICE_NAME} -> ${RUN_URL:-(not deployed yet)} (ingress: ${INGRESS})
Next steps (see https://code.claude.com/docs/en/claude-apps-gateway-on-gcp):
- Create the one operator-provided secret (from the Google Cloud Console OAuth client):
printf '%s' "<client-secret>" | gcloud secrets create ${OIDC_SECRET_NAME} \\
--data-file=- --project="${PROJECT_ID}"
setup.sh grants ${SA_EMAIL} secretAccessor on it on the next re-run.
- Fill in the REPLACE_ME values in ${GATEWAY_YAML}, then re-run: setup.sh publishes
${CONFIG_SECRET} and deploys ${SERVICE_NAME} once both secrets exist.
- After the first deploy: set listen.public_url to the Cloud Run URL above (or your
internal ALB hostname) and register <url>/oauth/callback on the Google OAuth client,
then re-run to redeploy.
- The gateway runs its own schema migrations at boot, so ${DB_USER} needs CREATE TABLE.
EOF

View File

@@ -0,0 +1,13 @@
# Never commit state (contains secrets) or local var files
*.tfstate
*.tfstate.*
.terraform/
terraform.tfvars
*.auto.tfvars
crash.log
# The lock file holds no secrets. It's ignored here so consumers who copy this
# example into their own repo generate (and commit) their own platform-complete
# lock at first init — committing one from this repo would carry only one
# platform's provider hashes.
.terraform.lock.hcl

View File

@@ -0,0 +1,160 @@
# Claude Gateway — Terraform (Cloud Run)
Terraform equivalent of `../setup.sh`. Lets end-users provision and manage
the gateway with `terraform apply`. Covers the same scope ([walkthrough](https://code.claude.com/docs/en/claude-apps-gateway-on-gcp) §17): APIs →
service account + IAM → Artifact Registry repo → VPC + Private Services Access →
private-IP Cloud SQL (PG16) → secrets → Cloud Run with Direct VPC egress.
## Files
| File | Purpose |
|------|---------|
| `versions.tf` | Provider pins (google, random) |
| `variables.tf` | All inputs (defaults match `setup.sh`'s) |
| `main.tf` | Resources |
| `outputs.tf` | Service URL, OAuth redirect URI, SA, DB info |
| `terraform.tfvars.example` | Copy to `terraform.tfvars` and edit |
## Prerequisites
1. **`../gateway.yaml` created and filled in** — copy the template first:
`cp ../gateway.yaml.example ../gateway.yaml`, then replace every `REPLACE_ME`
(Terraform reads this file and enforces no `REPLACE_ME` via a precondition).
Leave `public_url` at its placeholder for the first apply; set it to the
`run.app` URL (the `service_url` output) or your LB hostname and re-apply.
`gateway.yaml` is gitignored; the committed template is `gateway.yaml.example`.
2. A **remote backend** for shared use (see below). State holds secrets — never commit it.
## Deploy
Terraform creates the Artifact Registry repo but does **not** build/push the
image, so the apply is two passes: a targeted apply to create the repo, then
build/push, then the full apply.
```bash
cp terraform.tfvars.example terraform.tfvars # edit it
terraform init
# 1. Create just the Artifact Registry repo (the -target warning is expected):
terraform apply -target=google_artifact_registry_repository.repo
# 2. Download the public Claude Code linux-x64 release binary (it includes the
# `gateway` subcommand; the Dockerfile picks it up at gcp/claude), verify its
# sha256 against the release manifest, then build and push the image:
BASE="https://downloads.claude.ai/claude-code-releases"
VERSION="$(curl -fsSL --proto '=https' "${BASE}/latest")"
curl -fL --proto '=https' --proto-redir '=https' -o ../claude \
"${BASE}/${VERSION}/linux-x64/claude"
WANT="$(curl -fsSL --proto '=https' "${BASE}/${VERSION}/manifest.json" \
| tr -d '[:space:]' | grep -oE '"linux-x64"[^}]*' | grep -oE '[a-f0-9]{64}' | head -1)"
[ "$(openssl dgst -sha256 ../claude | awk '{print $NF}')" = "${WANT}" ] \
&& echo "sha256 OK" || { echo "checksum mismatch" >&2; rm -f ../claude; }
gcloud auth configure-docker us-east5-docker.pkg.dev --quiet
docker build --platform=linux/amd64 --provenance=false \
-f ../Dockerfile -t "us-east5-docker.pkg.dev/<project>/claude-gateway/gateway:${VERSION}" ..
docker push "us-east5-docker.pkg.dev/<project>/claude-gateway/gateway:${VERSION}"
# 3. Full apply:
terraform apply
```
(`../setup.sh` §3 automates the same download-and-verify.)
Set in `terraform.tfvars`:
- `project_id`, `region`
- `image_tag` (after building/pushing — step 2 above)
- **`oidc_client_secret`** — required (the Cloud Run service mounts `latest` of
this secret; with no version the deploy fails). Terraform creates the
secret + version from it.
- `invoker_iam_disabled` / `allow_unauthenticated` — the gateway runs its own
OIDC, so the Cloud Run invoker IAM check must be opened or disabled.
**Preferred:** `invoker_iam_disabled = true` (no `allUsers` binding; works
under Domain Restricted Sharing). **Fallback:** `allow_unauthenticated = true`
grants `allUsers` `run.invoker` — fine on a normal org, but DRS orgs reject
`allUsers` (set it `false` there, since an LB does **not** bypass the IAM
check). If both paths are blocked by org policy, use the GKE track.
- `ingress` — defaults to **internal-only** (no public URL). Claude Code's `/login`
only accepts gateway hosts on private addresses, so public ingress cannot serve
clients; the two-pass OAuth bootstrap must be completed from inside the VPC (or a
PSC-connected corp network). See "Private access" below.
Tear down a trial with `terraform destroy`: set `deletion_protection = false`,
run `terraform apply` to record that in state (the provider checks the value in
**state**, not config, so destroy would still refuse otherwise), then `terraform
destroy`. The destroy will stop at the VPC network
because the Private Services Access peering is intentionally left in place
(`deletion_policy = ABANDON` — see Guard rails below); finish by deleting the
peering manually once the Cloud SQL instance is gone, then re-run destroy:
```bash
gcloud services vpc-peerings delete --service=servicenetworking.googleapis.com \
--network=cc-gateway-vpc --project=<project>
terraform destroy
```
## Guard rails
Tuned so accidental deletion is hard but greenfield teardown stays easy:
- `deletion_protection = true` (variable, default true) on Cloud SQL and Cloud Run —
blocks accidental deletion; set `false` when you intend to `terraform destroy`.
- `disable_on_destroy = false` on APIs — tearing down config never disables APIs.
- `deletion_policy = ABANDON` on the PSA peering — never tears down the
service-networking peering automatically (it's shared by every private-IP
service on the VPC). On the dedicated VPC this module creates, that means
`terraform destroy` stops at the network step; delete the peering manually
per the teardown note above.
- IAM uses non-authoritative `_member` resources, so other project/secret bindings
are never clobbered.
## Private access (internal ingress) — the default
By default the service has **no public URL** (`ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY"`),
and there is no public-ingress option: Claude Code's `/login` rejects gateway hosts that
resolve to public addresses, so public exposure cannot serve clients. Reach the service
from inside the VPC, or via the private-access plumbing below.
With internal-only ingress, `public_url` stays the `run.app` URL (Google-managed cert) —
**no load balancer or your own certificate required**. But internal ingress alone does
**not** let corporate on-prem clients reach `run.app`; that needs **operator /
network-team-owned** plumbing that Cloud Run does **not** create for you (validate it's in
place before relying on internal ingress):
1. A **Private Service Connect endpoint** for Google APIs (an internal VIP in the VPC).
2. A **Cloud DNS private zone for `run.app`** resolving `*.run.app` to that endpoint IP.
3. **On-prem routing** to the endpoint over Cloud VPN / Interconnect.
This is normally managed centrally in the network/hub project, so the module does not
provision it. See [Private networking and Cloud Run](https://cloud.google.com/run/docs/securing/private-networking).
For a greenfield trial without this plumbing, complete the OAuth bootstrap from inside
the VPC — e.g. a browser proxied through an in-VPC VM (SSH SOCKS tunnel over IAP).
For a **custom internal hostname or your own TLS cert**, use
`INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER` and front the service with your own internal
Application Load Balancer (also not provisioned by this module).
## Remote state (recommended for teams)
Add a backend so state is shared and locked (and out of git):
```hcl
# backend.tf
terraform {
backend "gcs" {
bucket = "<your-tf-state-bucket>"
prefix = "claude-gateway/cloudrun"
}
}
```
## After deploy
- `terraform output service_url` / `oauth_redirect_uri`.
- Register the redirect URI on the Google OAuth client and make sure
`../gateway.yaml` `public_url` matches the host.
- Notes: Terraform does not build the image. To ship a new gateway version,
rerun the docker build/push under a new tag and bump `image_tag` — a bare
re-apply under an unchanged tag does **not** roll a new revision (Cloud Run
resolves the tag to a digest only at revision creation, and an unchanged
`image` attribute means no new revision).

View File

@@ -0,0 +1,399 @@
# Claude Gateway on Cloud Run — Terraform equivalent of setup.sh.
# Section markers (§N) map to setup.sh and the walkthrough:
# https://code.claude.com/docs/en/claude-apps-gateway-on-gcp
locals {
config_path = var.gateway_config_path != "" ? var.gateway_config_path : "${path.module}/../gateway.yaml"
gateway_config = file(local.config_path)
image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.ar_repo}/${var.image_name}:${var.image_tag}"
apis = [
"aiplatform.googleapis.com",
"artifactregistry.googleapis.com",
"cloudresourcemanager.googleapis.com",
"sqladmin.googleapis.com",
"secretmanager.googleapis.com",
"iamcredentials.googleapis.com",
"iam.googleapis.com",
"compute.googleapis.com",
"servicenetworking.googleapis.com",
"run.googleapis.com",
]
}
# ── 1 Project & API setup ───────────────────────────────────────────────────
resource "google_project_service" "apis" {
for_each = toset(local.apis)
project = var.project_id
service = each.value
# Don't disable APIs (or delete anything) when this config is torn down.
disable_on_destroy = false
disable_dependent_services = false
}
# ── 2 Service account & IAM (least-privilege) ───────────────────────────────
resource "google_service_account" "gateway" {
project = var.project_id
account_id = var.sa_name
display_name = "Claude Gateway"
depends_on = [google_project_service.apis]
}
# Non-authoritative (_member) so we never clobber other project bindings.
#
# Only aiplatform.user is granted: the gateway reaches Cloud SQL over the VPC at
# its private IP with a password user (direct TCP via Direct VPC egress — see §7
# below), not via the Cloud SQL Auth Proxy / connector, so it never calls
# cloudsql.instances.connect and no roles/cloudsql.client grant is needed.
# Direct private-IP keeps the gateway's store a plain postgres_url with no proxy
# sidecar/socket plumbing, and the connection string is portable across Cloud
# Run and GKE.
resource "google_project_iam_member" "vertex" {
project = var.project_id
role = "roles/aiplatform.user" # Agent Platform inference
member = "serviceAccount:${google_service_account.gateway.email}"
}
# ── 3 Artifact Registry repo ────────────────────────────────────────────────
# NOTE: image build/push is a separate step (see README) — Terraform only makes the repo.
resource "google_artifact_registry_repository" "repo" {
project = var.project_id
location = var.region
repository_id = var.ar_repo
format = "DOCKER"
description = "Claude Gateway container images"
depends_on = [google_project_service.apis]
}
# ── 4 VPC + Private Services Access ──────────────────────────────────────────
resource "google_compute_network" "vpc" {
project = var.project_id
name = var.vpc_network
auto_create_subnetworks = false
depends_on = [google_project_service.apis]
}
resource "google_compute_subnetwork" "subnet" {
project = var.project_id
name = var.subnet
region = var.region
network = google_compute_network.vpc.id
ip_cidr_range = var.subnet_range
}
resource "google_compute_global_address" "psa_range" {
project = var.project_id
name = "google-managed-services-${var.vpc_network}"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = var.psa_prefix_length
network = google_compute_network.vpc.id
}
resource "google_service_networking_connection" "psa" {
network = google_compute_network.vpc.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.psa_range.name]
# ABANDON: on destroy, leave the producer peering in place (deleting it can hang
# and would affect any other private-IP service on this VPC).
deletion_policy = "ABANDON"
# If the peering already exists (e.g. a previous apply failed partway), patch it
# instead of failing the create.
update_on_creation_fail = true
depends_on = [google_project_service.apis]
}
# ── 4 Cloud SQL (private IP only) ───────────────────────────────────────────
resource "google_sql_database_instance" "db" {
project = var.project_id
name = var.db_instance
region = var.region
database_version = var.db_version
deletion_protection = var.deletion_protection
depends_on = [google_service_networking_connection.psa]
settings {
tier = var.db_tier
ip_configuration {
ipv4_enabled = false # private IP only (org policy: sql.restrictPublicIp)
private_network = google_compute_network.vpc.id
ssl_mode = "ENCRYPTED_ONLY"
}
}
}
resource "google_sql_database" "db" {
project = var.project_id
name = var.db_name
instance = google_sql_database_instance.db.name
}
# URL-safe (alphanumeric) so it drops cleanly into the connection string.
# nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
resource "random_password" "db" {
length = 32
special = false
}
# nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
resource "google_sql_user" "gateway" {
project = var.project_id
name = var.db_user
instance = google_sql_database_instance.db.name
password = random_password.db.result
# On destroy the role owns the tables it migrated at boot, so DROP ROLE can
# fail (and races google_sql_database.db). ABANDON is harmless on the
# greenfield teardown — the whole instance is deleted anyway.
deletion_policy = "ABANDON"
}
# ── 5/6 Secrets + secretAccessor ────────────────────────────────────────────
# postgres-url: connection string built from the instance's private IP.
resource "google_secret_manager_secret" "postgres_url" {
project = var.project_id
secret_id = var.secret_name
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
# nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
resource "google_secret_manager_secret_version" "postgres_url" {
secret = google_secret_manager_secret.postgres_url.id
secret_data = "postgres://${var.db_user}:${random_password.db.result}@${google_sql_database_instance.db.private_ip_address}:5432/${var.db_name}?sslmode=require"
}
# jwt: session signing key.
# nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
resource "random_password" "jwt" {
length = 48
special = false
}
resource "google_secret_manager_secret" "jwt" {
project = var.project_id
secret_id = var.jwt_secret_name
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
# nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
resource "google_secret_manager_secret_version" "jwt" {
secret = google_secret_manager_secret.jwt.id
secret_data = random_password.jwt.result
}
# oidc client secret: operator-provided (from the Google OAuth client).
resource "google_secret_manager_secret" "oidc" {
project = var.project_id
secret_id = var.oidc_secret_name
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
# nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
resource "google_secret_manager_secret_version" "oidc" {
count = var.oidc_client_secret != "" ? 1 : 0
secret = google_secret_manager_secret.oidc.id
secret_data = var.oidc_client_secret
}
# Warn (not block) at plan time when the OIDC secret value isn't set: the Cloud
# Run service mounts gateway-oidc-client-secret:latest unconditionally, so an
# empty value with no out-of-band version means the apply fails late at
# revision creation. A warning (not a precondition) keeps the documented
# out-of-band-version mode usable.
check "oidc_client_secret_set" {
assert {
condition = var.oidc_client_secret != ""
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)."
}
}
# config: gateway.yaml. Guard mirrors the bash REPLACE_ME check (non-comment lines).
resource "google_secret_manager_secret" "config" {
project = var.project_id
secret_id = var.config_secret_name
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
# nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
resource "google_secret_manager_secret_version" "config" {
secret = google_secret_manager_secret.config.id
secret_data = local.gateway_config
lifecycle {
precondition {
condition = length([
for line in split("\n", local.gateway_config) :
line
if !startswith(trimspace(line), "#") && strcontains(line, "REPLACE_ME")
]) == 0
error_message = "gateway.yaml still has REPLACE_ME on a non-comment line — fill it in before applying."
}
}
}
resource "google_secret_manager_secret_iam_member" "postgres_url" {
project = var.project_id
secret_id = google_secret_manager_secret.postgres_url.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gateway.email}"
}
resource "google_secret_manager_secret_iam_member" "jwt" {
project = var.project_id
secret_id = google_secret_manager_secret.jwt.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gateway.email}"
}
resource "google_secret_manager_secret_iam_member" "oidc" {
project = var.project_id
secret_id = google_secret_manager_secret.oidc.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gateway.email}"
}
resource "google_secret_manager_secret_iam_member" "config" {
project = var.project_id
secret_id = google_secret_manager_secret.config.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gateway.email}"
}
# ── 7 Cloud Run (Direct VPC egress) ─────────────────────────────────────────
resource "google_cloud_run_v2_service" "gateway" {
project = var.project_id
name = var.service_name
location = var.region
ingress = var.ingress
invoker_iam_disabled = var.invoker_iam_disabled
deletion_protection = var.deletion_protection
template {
service_account = google_service_account.gateway.email
scaling {
min_instance_count = var.min_instances
max_instance_count = var.max_instances
}
# Secrets are mounted at version=latest, so a config edit or secret
# rotation alone wouldn't diff this resource and the warm min_instances=1
# revision would keep the old values. Stamping a hash of the rendered
# config + every managed secret value forces a new revision whenever any
# of them change — without this, tainting random_password.db ALTERs the
# SQL role to the new password while the running revision keeps the old
# connection string and breaks on its next reconnect, and rotating the
# OIDC client secret leaves login failing invalid_client.
labels = {
config-sha = substr(sha256(join("", [
local.gateway_config,
random_password.db.result,
random_password.jwt.result,
var.oidc_client_secret,
])), 0, 63)
}
# Cloud Run's default 300s request timeout would cut off long streaming
# /v1/messages responses mid-stream.
timeout = "3600s"
vpc_access {
network_interfaces {
network = google_compute_network.vpc.id
subnetwork = google_compute_subnetwork.subnet.id
}
egress = "PRIVATE_RANGES_ONLY" # public egress (Agent Platform, accounts.google.com) bypasses the VPC -> no Cloud NAT needed
}
containers {
image = local.image
ports { container_port = 8080 }
# gateway.yaml mounted as a file at /etc/claude/gateway.yaml (alone in its dir).
volume_mounts {
name = "config"
mount_path = "/etc/claude"
}
# Cloud Run can't mount multiple secrets in one dir, so the rest are env vars
# (gateway.yaml references them via ${ENV_VAR}).
env {
name = "GATEWAY_JWT_SECRET"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.jwt.secret_id
version = "latest"
}
}
}
env {
name = "OIDC_CLIENT_SECRET"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.oidc.secret_id
version = "latest"
}
}
}
env {
name = "GATEWAY_POSTGRES_URL"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.postgres_url.secret_id
version = "latest"
}
}
}
}
volumes {
name = "config"
secret {
secret = google_secret_manager_secret.config.secret_id
items {
path = "gateway.yaml"
version = "latest"
}
}
}
}
depends_on = [
google_secret_manager_secret_iam_member.config,
google_secret_manager_secret_iam_member.jwt,
google_secret_manager_secret_iam_member.oidc,
google_secret_manager_secret_iam_member.postgres_url,
google_secret_manager_secret_version.config,
google_secret_manager_secret_version.postgres_url,
google_secret_manager_secret_version.jwt,
google_secret_manager_secret_version.oidc,
google_sql_database.db,
google_sql_user.gateway,
google_project_service.apis,
]
}
# Public access at the Cloud Run IAM layer — the gateway runs its own OIDC, so the
# invoker check must be opened or disabled (real auth stays the gateway's SSO):
# Preferred — disable it: invoker_iam_disabled=true on the service above. No allUsers
# binding at all, and it works under Domain Restricted Sharing.
# Fallback — open it: this allUsers run.invoker grant. Domain Restricted Sharing orgs
# reject allUsers, and an LB does NOT bypass that (ingress is network-layer; the IAM
# check still runs) — use invoker_iam_disabled, a DRS exception, or GKE.
# Skipped when invoker_iam_disabled=true (the grant would be redundant, and DRS rejects it).
resource "google_cloud_run_v2_service_iam_member" "public" {
count = var.allow_unauthenticated && !var.invoker_iam_disabled ? 1 : 0
project = var.project_id
location = var.region
name = google_cloud_run_v2_service.gateway.name
role = "roles/run.invoker"
member = "allUsers"
}

View File

@@ -0,0 +1,34 @@
output "service_url" {
description = "Cloud Run service URL."
value = google_cloud_run_v2_service.gateway.uri
}
output "oauth_redirect_uri" {
description = "Register this exact URI on the Google OAuth client, and ensure gateway.yaml public_url matches the host."
value = "${google_cloud_run_v2_service.gateway.uri}/oauth/callback"
}
output "service_account_email" {
description = "Gateway runtime service account."
value = google_service_account.gateway.email
}
output "image" {
description = "Image the service runs (build/push this separately — see README)."
value = local.image
}
output "db_connection_name" {
description = "Cloud SQL instance connection name (project:region:instance)."
value = google_sql_database_instance.db.connection_name
}
output "db_private_ip" {
description = "Cloud SQL private IP."
value = google_sql_database_instance.db.private_ip_address
}
output "public_invoker_granted" {
description = "Whether the allUsers run.invoker binding was applied (false when invoker_iam_disabled handles public access instead, or on Domain-Restricted-Sharing orgs)."
value = length(google_cloud_run_v2_service_iam_member.public) > 0
}

View File

@@ -0,0 +1,26 @@
# Copy to terraform.tfvars and edit. terraform.tfvars is gitignored (see .gitignore).
project_id = "your-gcp-project-id"
region = "us-east5"
image_tag = "<version>" # REQUIRED — the Claude Code release version you build and push as linux/amd64 (see README Deploy)
# Public access at the Cloud Run IAM layer (the gateway runs its own OIDC):
# Preferred — disable the invoker check: no allUsers binding, works under Domain
# Restricted Sharing. Needs google provider >= 6.8 and the feature enabled for your org:
# invoker_iam_disabled = true
# Fallback — grant allUsers (fine on a normal org; Domain Restricted Sharing rejects it,
# so there prefer invoker_iam_disabled, or use a DRS exception / GKE):
allow_unauthenticated = true
# Network reachability — a separate axis from the IAM choice above. Default is internal-only:
# no public URL (Claude Code's /login only accepts gateway hosts on private addresses, so
# public ingress cannot serve clients); corp on-prem reaches run.app via a PSC endpoint +
# private run.app DNS — see README "Private access" for the prerequisites. The only
# alternative to the internal-only default:
# ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" # only if you front it with your OWN internal ALB (custom hostname/cert; not provisioned here)
# Google OAuth client secret: REQUIRED — uncomment and set it (Terraform creates the
# secret version; the Cloud Run service mounts `latest`, so without a version the
# deploy fails). Leave empty only if you add the secret version out-of-band.
# oidc_client_secret = "GOCSPX-..."

View File

@@ -0,0 +1,184 @@
# Inputs — mirror the env-overridable knobs in setup.sh (same defaults).
variable "project_id" {
description = "GCP project ID."
type = string
}
variable "region" {
description = "Infra region for Artifact Registry, Cloud SQL, subnet, and Cloud Run. (Agent Platform region is set separately inside gateway.yaml.)"
type = string
default = "us-east5"
}
# ── Service account (§2) ────────────────────────────────────────────────────
variable "sa_name" {
description = "Service account account_id (the part before @)."
type = string
default = "claude-gateway"
}
# ── Image (§3) ──────────────────────────────────────────────────────────────
# Terraform creates the Artifact Registry repo but does NOT build/push the image
# (that's a docker build step — see README). It references the image by tag.
variable "ar_repo" {
description = "Artifact Registry Docker repository ID."
type = string
default = "claude-gateway"
}
variable "image_name" {
description = "Image name within the repo."
type = string
default = "gateway"
}
variable "image_tag" {
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."
type = string
validation {
condition = can(regex("^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$", var.image_tag))
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)."
}
}
# ── Networking (§4) ─────────────────────────────────────────────────────────
variable "vpc_network" {
description = "Custom VPC network name."
type = string
default = "cc-gateway-vpc"
}
variable "subnet" {
description = "Subnet name (Cloud Run Direct VPC egress attaches here)."
type = string
default = "cc-gateway-subnet"
}
variable "subnet_range" {
description = "Subnet primary CIDR."
type = string
default = "10.0.0.0/24"
}
variable "psa_prefix_length" {
description = "Prefix length for the Private Services Access allocated range (/16 is GCP's recommendation)."
type = number
default = 16
}
# ── Cloud SQL (§4) ──────────────────────────────────────────────────────────
variable "db_instance" {
description = "Cloud SQL instance name."
type = string
default = "claude-gateway-db"
}
variable "db_version" {
description = "Postgres major version. The gateway supports PostgreSQL 14 or newer; 16 is the recommended default."
type = string
default = "POSTGRES_16"
}
variable "db_tier" {
description = "Cloud SQL machine tier."
type = string
default = "db-g1-small"
}
variable "db_name" {
description = "Database name."
type = string
default = "claude_gateway"
}
variable "db_user" {
description = "Database user (the gateway connects as this role)."
type = string
default = "gateway"
}
# ── Secrets (§5 / §6) ─────────────────────────────────────────────────────
variable "secret_name" {
description = "Secret Manager secret holding the Postgres connection string."
type = string
default = "gateway-postgres-url"
}
variable "jwt_secret_name" {
description = "Secret Manager secret holding the session JWT signing key."
type = string
default = "gateway-jwt-secret"
}
variable "oidc_secret_name" {
description = "Secret Manager secret holding the Google OAuth client secret."
type = string
default = "gateway-oidc-client-secret"
}
variable "config_secret_name" {
description = "Secret Manager secret holding gateway.yaml (mounted at /etc/claude/gateway.yaml)."
type = string
default = "gateway-config"
}
variable "oidc_client_secret" {
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)."
type = string
default = ""
sensitive = true
}
variable "gateway_config_path" {
description = "Path to gateway.yaml. Empty = ../gateway.yaml relative to this module."
type = string
default = ""
}
# ── Cloud Run (§7) ──────────────────────────────────────────────────────────
variable "service_name" {
description = "Cloud Run service name."
type = string
default = "claude-gateway"
}
variable "min_instances" {
description = "Minimum Cloud Run instances (1 avoids cold OIDC discovery)."
type = number
default = 1
}
variable "max_instances" {
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."
type = number
default = 8
}
variable "ingress" {
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)."
type = string
default = "INGRESS_TRAFFIC_INTERNAL_ONLY"
validation {
condition = contains(["INGRESS_TRAFFIC_INTERNAL_ONLY", "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"], var.ingress)
error_message = "ingress must be INGRESS_TRAFFIC_INTERNAL_ONLY or INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER — Claude Code only connects to gateways on private addresses."
}
}
variable "invoker_iam_disabled" {
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."
type = bool
default = false
}
variable "allow_unauthenticated" {
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."
type = bool
default = true
}
variable "deletion_protection" {
description = "Provider-level deletion protection on Cloud SQL and Cloud Run. Keep true to avoid accidental deletion of the running deployment."
type = bool
default = true
}

View File

@@ -0,0 +1,19 @@
# Provider + version pins for the Claude Gateway Cloud Run deployment.
terraform {
required_version = ">= 1.5"
required_providers {
google = {
source = "hashicorp/google"
version = ">= 6.8, < 7.0" # 6.8 adds invoker_iam_disabled on google_cloud_run_v2_service
}
random = {
source = "hashicorp/random"
version = ">= 3.5"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}

1096
feed.xml

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "frontend-design",
"version": "1.0.0",
"version": "1.1.0",
"description": "Frontend design skill for UI/UX implementation",
"author": {
"name": "Prithvi Rajasekaran, Alexander Bricken",

View File

@@ -1,42 +1,55 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
# Frontend Design
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. This client has already rejected proposals that felt templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take one real aesthetic risk you can justify.
## Design Thinking
## Ground it in the subject
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
If the brief does not pin down what the product or subject is, pin it yourself before designing: name one concrete subject, its audience, and the page's single job, and state your choice. If there's any information in your memory about the human's preferences, context about what they're building, or designs you've made before use that as a hint. The subject's own world, its materials, instruments, artifacts, and vernacular, is where distinctive choices come from. Build with the brief's real content and subject matter throughout.
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
## Design principles
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world, in whatever form makes sense for it: a headline, an image, an animation, a live demo, an interactive moment. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the template answer, only use if that's truly the best option.
## Frontend Aesthetics Guidelines
Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design, not a neutral delivery vehicle for the content.
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
Structure is information. Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
## Process: brainstorm, explore, plan, critique, build, critique again
For calibration: AI-generated design right now clusters around three looks: (1) a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta accent; (2) a near-black background with a single bright acid-green or vermilion accent; (3) a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns. All three are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. Just like a human designer who's hired, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn.
Work in two passes. First, brainstorm a short design plan based on the human's design brief: create a compact token system with color, type, layout, and signature. Color: describe the palette as 46 named hex values. Type: the typefaces for 2+ roles (a characterful display face that's used with restraint, a complementary body face, and a utility face for captions or data if needed). Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Signature: the single unique element this page will be remembered by that embodies the brief in an appropriate way.
Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan exactly and deriving every color and type decision from it.
When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and a element-based selector like .cta). This can happen often with paddings/margins between sections.
Try to do a lot of this planning and iteration in your thinking, and only show ideas to the user when you have higher confidence it'll delight them.
## Restraint and self-critique
Spend your boldness in one place. Let the signature element be the one memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Not taking a risk can be a risk itself! Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected. Critique your own work as you build, taking screenshots if your environment supports it a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creators have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes.
## More on writing in design
Words appear in a design for one reason: to make it easier to understand, and therefore easier to use. They are design material, not decoration. Bring the same intentionality to copy that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience.
Write from the end user's side of the screen. Name things by what people control and recognize, never by how the system is built. A person manages notifications, not webhook config. Describe what something does in plain terms rather than selling it. Being specific is always better than being clever.
Use active voice as default. A control should say exactly what happens when it's used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around.
Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act.
Keep the register conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each element do exactly one job. A label labels, an example demonstrates, and nothing quietly does double duty.