Claude Code with GCP: Cloud Run, IAM, BigQuery Workflow
Using Claude Code on GCP projects
Google Cloud is where Claude Code can move fastest and where it can also do the most quiet damage. The surface area is wide: Cloud Run, Cloud Functions, App Engine, IAM, Cloud Storage, BigQuery, Pub/Sub, Secret Manager. Claude knows the shape of all of it but not which compute primitive your team uses, which projects are production, or which service accounts already exist.
Without a project-specific CLAUDE.md, Claude grants roles/owner because the tutorial it saw did, mixes gcloud beta with stable commands, deploys revisions with --allow-unauthenticated, and writes BigQuery queries that scan terabytes. Worse, it can run gcloud run deploy against the wrong project because none is pinned.
This guide covers the CLAUDE.md patterns that prevent those failures. New to Claude Code? See the setup guide and CLAUDE.md explained. For parallel cloud work, see the AWS guide and Azure guide.
The GCP CLAUDE.md template
The CLAUDE.md at your project root is read before every Claude Code session. For a GCP project it declares the compute primitive, the IaC tool, the projects and regions in scope, and the IAM rules that govern every binding Claude writes.
# GCP project rules
## Stack
- IaC: Terraform for shared infra, gcloud CLI for per-service deploys
- Primary compute: Cloud Run, 2nd-gen Cloud Functions for event glue
- Legacy: App Engine Standard (read-only, do not extend)
- Region: europe-west2, multi-region only when justified
- Projects: acme-dev, acme-staging, acme-prod (via gcloud configurations)
- Artifact Registry: europe-west2-docker.pkg.dev/acme-shared/services
## Project structure
- terraform/: root + modules, one folder per environment
- services/{name}/: one folder per Cloud Run service (src/, Dockerfile, cloudbuild.yaml)
- functions/{name}/: 2nd-gen Cloud Functions source
- scripts/: operational, never auto-run in CI
## Naming and labelling
- Services: kebab-case verb-noun (process-order, archive-invoice)
- Service accounts: ${service}-sa@${project}.iam.gserviceaccount.com
- Buckets: ${project}-${purpose}-${region}
- BigQuery datasets: ${stage}_${domain} (prod_orders, staging_billing)
- Pub/Sub topics: ${domain}.${event} (orders.created)
- All Terraform resources label: env, owner, cost_center, managed_by=terraform
## IAM rules (HARD)
- NEVER grant roles/owner, roles/editor, or roles/viewer to a service account
- One service account per Cloud Run service, scoped to its specific needs
- Use granular predefined roles (roles/run.invoker, roles/datastore.user) before custom roles
- Cross-service calls use IAM-authenticated invocations, not API keys
- All bindings in Terraform or `gcloud iam`, never the console
- If a role ID is unknown, ASK before guessing
## Deploy rules
- `gcloud config configurations activate ${stage}` before any deploy
- `terraform plan` reviewed before every `terraform apply`
- Production deploys go through Cloud Build triggers, never Claude's shell
- Cloud Run deploys require `--no-allow-unauthenticated` unless genuinely public
- Destructive commands (terraform destroy, projects delete, bq rm) denied for Claude
Three rules in this template prevent the most common Claude Code failures with GCP.
The no primitive roles rule is the most consequential. GCP docs and Stack Overflow answers are full of roles/owner shortcuts. Without an explicit prohibition, Claude grants those to service accounts, and a leaked credential then has full project access.
The project pinning rule prevents the worst foot-gun: running gcloud run deploy while the active configuration points at production. Forcing an explicit gcloud config configurations activate step makes the target visible in the transcript.
The ask-do-not-guess rule is the antidote to a known failure mode. Claude invents plausible role names (roles/bigquery.reader) that do not match real IDs (roles/bigquery.dataViewer). Telling Claude to ask turns a silent fabrication into a question.
Cloud Run, Cloud Functions, or App Engine
The first decision on GCP is the compute primitive. Claude defaults to whatever appeared most in training, usually App Engine. CLAUDE.md makes the choice explicit.
## Compute primitive choice
### Cloud Run (default)
- HTTP services, APIs, web apps, anything that ships as a container
- Concurrency 80 per instance default, max instances always capped
- Min instances 0 for non-critical, 1+ for latency-sensitive paths
### Cloud Functions 2nd gen
- Event glue only (Pub/Sub triggers, Eventarc, GCS events)
- Scheduled jobs via Cloud Scheduler -> Pub/Sub -> Function
- 2nd gen only, never 1st gen for new code
### App Engine Standard
- Legacy only. Do not extend. Migrate on next significant change.
### When in doubt
- HTTP-shaped -> Cloud Run
- Event-shaped -> Cloud Functions 2nd gen
- Batch -> Cloud Run Jobs
- WebSockets -> Cloud Run with concurrency tuned
- ASK before reaching for Cloud Tasks, Workflows, or Dataflow
The choice matters because the failure modes differ. Cloud Functions has rigid invocation, App Engine Standard locks you into runtime versions that do not fit modern CI, Cloud Run gives the same scale-to-zero with full container control and a deploy model that fits Terraform and Cloud Build cleanly.
Without explicit guidance, Claude reaches for Functions for every small service because tutorials are short, and you end up with a fleet sharing the same dependency tree and cold start. One Cloud Run service with sensible concurrency replaces ten functions and ships faster.
IAM patterns and service accounts that hold up under review
IAM is where GCP engineering is won or lost. Correct design is least-privilege by default, uses workload identity for cross-service access, and never relies on long-lived service account keys. Claude produces IAM that meets this bar with explicit pattern guidance.
# terraform/modules/cloud-run-service/main.tf
resource "google_service_account" "sa" {
account_id = "${var.service_name}-sa"
display_name = "Cloud Run SA for ${var.service_name}"
project = var.project_id
}
# Granular role, scoped by IAM Condition to declared secrets only
resource "google_project_iam_member" "secret_accessor" {
count = length(var.secret_ids) > 0 ? 1 : 0
project = var.project_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.sa.email}"
condition {
title = "scoped_to_named_secrets"
expression = join(" || ", [
for s in var.secret_ids :
"resource.name == 'projects/${var.project_id}/secrets/${s}'"
])
}
}
resource "google_bigquery_dataset_iam_member" "reader" {
for_each = toset(var.bq_read_datasets)
dataset_id = each.value
role = "roles/bigquery.dataViewer"
member = "serviceAccount:${google_service_account.sa.email}"
project = var.project_id
}
# Cloud Run uses the dedicated SA, never the default compute SA
resource "google_cloud_run_v2_service" "svc" {
name = var.service_name
location = var.region
project = var.project_id
template {
service_account = google_service_account.sa.email
containers { image = var.image }
scaling {
min_instance_count = var.min_instances
max_instance_count = var.max_instances
}
}
}
# Cross-service: invoker role, not shared keys
resource "google_cloud_run_service_iam_member" "invokers" {
for_each = toset(var.invoker_sas)
service = google_cloud_run_v2_service.svc.name
location = var.region
role = "roles/run.invoker"
member = "serviceAccount:${each.value}"
project = var.project_id
}
The dedicated SA per service is the foundation. Without it, every service inherits the default compute SA, which historically holds roles/editor project-wide. One leaked token becomes project-wide write. One SA per service limits the blast radius to that service's declared roles.
The IAM Condition on secretAccessor turns Secret Manager from a soft control into a strong one. The role is granted, but the binding only resolves when the requested resource matches a declared secret. Without the condition, Claude grants blanket Secret Manager access.
The cross-service run.invoker pattern replaces API keys entirely: caller authenticates as itself, receiver checks the IAM binding, no static credentials anywhere. For locking Claude Code's runtime permissions to match this IAM scope, see the permissions guide.
Cloud Storage, BigQuery, and Pub/Sub patterns
The three data services in nearly every GCP project have predictable failure modes without guidance. Each has a small set of conventions that make the code production-ready.
// services/orders/src/lib/gcp.ts
import { Storage } from '@google-cloud/storage';
import { BigQuery } from '@google-cloud/bigquery';
import { PubSub } from '@google-cloud/pubsub';
const projectId = requireEnv('GCP_PROJECT');
// Single client per process, reused across requests
export const storage = new Storage({ projectId });
export const bq = new BigQuery({ projectId, location: 'EU' });
export const pubsub = new PubSub({ projectId });
// Cloud Storage: signed URLs, never public buckets
export async function signedReadUrl(orderId: string) {
const file = storage.bucket(requireEnv('ARCHIVE_BUCKET')).file(`orders/${orderId}.json`);
const [url] = await file.getSignedUrl({
version: 'v4',
action: 'read',
expires: Date.now() + 15 * 60 * 1000,
});
return url;
}
// BigQuery: parameterised, partition filter + cost cap mandatory
export async function ordersByDay(day: string) {
const [rows] = await bq.query({
query: `SELECT customer_id, COUNT(*) AS orders
FROM \`${projectId}.prod_orders.orders\`
WHERE _PARTITIONDATE = @day
GROUP BY customer_id`,
params: { day },
types: { day: 'DATE' },
maximumBytesBilled: '10000000000', // 10GB cap
});
return rows;
}
// Pub/Sub: typed attributes, ordering key per aggregate
export async function publishOrderCreated(orderId: string, payload: unknown) {
const topic = pubsub.topic('orders.created', { messageOrdering: true });
await topic.publishMessage({
data: Buffer.from(JSON.stringify(payload)),
attributes: { eventType: 'orders.created', orderId, version: '1' },
orderingKey: orderId,
});
}
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Required env var not set: ${name}`);
return v;
}
The single client per process amortises across thousands of invocations because Cloud Run reuses instances.
The maximumBytesBilled cap is the most important line in any BigQuery code. BigQuery charges by data scanned, and a missing partition filter can scan a multi-terabyte table by accident. The cap fails the query before it bankrupts the project. The environment variables guide covers the validation pattern for the env vars used here.
The Pub/Sub ordering key turns a topic from best-effort fanout into an ordered queue per key. Signed URLs are the safe alternative to public buckets: Claude reaches for bucket.makePublic() when training examples show it, and the application-layer pattern keeps the bucket private with time-bounded reads.
Secret Manager and the no-keys rule
Long-lived service account keys are the biggest source of cloud incidents. Workload identity, Secret Manager, and IAM-authenticated invocations replace keys entirely. The CLAUDE.md rule is absolute.
## Secrets and credentials (HARD)
- NEVER create service account keys for application code
- NEVER store credentials in env vars, code, or version control
- NEVER use API keys for service-to-service GCP calls
- All secrets in Secret Manager, accessed at runtime via the API
- Cloud Run services authenticate via attached service account, not keys
- Local dev uses Application Default Credentials (gcloud auth application-default login)
- Cloud Build authenticates via the Cloud Build service account
The Secret Manager runtime access pattern looks like this in practice:
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
const smc = new SecretManagerServiceClient();
const projectId = requireEnv('GCP_PROJECT');
const secretCache = new Map<string, string>();
export async function getSecret(name: string, version = 'latest'): Promise<string> {
const key = `${name}@${version}`;
const cached = secretCache.get(key);
if (cached) return cached;
const [accessed] = await smc.accessSecretVersion({
name: `projects/${projectId}/secrets/${name}/versions/${version}`,
});
const payload = accessed.payload?.data?.toString();
if (!payload) throw new Error(`Secret ${name} returned empty payload`);
secretCache.set(key, payload);
return payload;
}
The module-scope cache is the difference between adding 50ms per request and fetching once per cold start. Secret Manager has a per-project quota and unbounded refetches will hit it.
For local dev, Claude is least likely to know the modern pattern unprompted. Engineers used to download a key file and set GOOGLE_APPLICATION_CREDENTIALS. The current path is gcloud auth application-default login, which produces short-lived user credentials. No key file to leak.
Deploy workflow with gcloud and Terraform
The deploy story combines Terraform for infrastructure and gcloud for service revisions. Both need explicit project scoping or they silently target the wrong environment.
# Activate the correct gcloud configuration first, always
gcloud config configurations activate dev
gcloud config list && gcloud auth list
# Terraform plan against the matching environment
cd terraform/dev
terraform init -backend-config=backend.hcl
terraform plan -out=tfplan # human review
terraform apply tfplan
# Cloud Run deploy (CI normally does this)
gcloud run deploy process-order \
--image=europe-west2-docker.pkg.dev/acme-shared/services/process-order:${COMMIT_SHA} \
--region=europe-west2 --project=acme-dev \
--service-account=process-order-sa@acme-dev.iam.gserviceaccount.com \
--no-allow-unauthenticated \
--min-instances=0 --max-instances=10 \
--set-env-vars=GCP_PROJECT=acme-dev,STAGE=dev \
--set-secrets=STRIPE_SECRET=stripe-secret:latest
The explicit --project flag on every gcloud run deploy survives shell-state mistakes. Even if the active configuration drifts, the flag wins.
The --no-allow-unauthenticated flag is the GCP equivalent of "drop privileges". Cloud Run defaults to public when no flag is set, fine for a marketing site, catastrophic for an internal API. Forcing the explicit choice makes every service's exposure visible in the deploy command.
For Terraform, the equivalent guardrails go in the root configuration:
# terraform/dev/main.tf
terraform {
required_version = ">= 1.7"
required_providers {
google = { source = "hashicorp/google", version = "~> 5.20" }
}
backend "gcs" {} # config from backend.hcl
}
provider "google" {
project = "acme-dev"
region = "europe-west2"
}
# Lock state, prevent accidental destroy
resource "google_storage_bucket" "tfstate" {
name = "acme-tfstate-dev"
location = "EU"
uniform_bucket_level_access = true
versioning { enabled = true }
lifecycle { prevent_destroy = true }
}
The prevent_destroy on the state bucket matters when Claude runs terraform destroy against the wrong directory. The destroy fails on the protected resource and the operator notices before everything else is gone.
Claude Code permissions and the destructive command list
Claude Code's permission system controls which Bash commands run autonomously. For GCP this is where you stop Claude from deleting projects or running gcloud against the wrong configuration.
{
"permissions": {
"allow": [
"Bash(npm test*)", "Bash(npm run build*)", "Bash(docker build*)",
"Bash(gcloud config list*)", "Bash(gcloud auth list*)",
"Bash(gcloud config configurations activate dev*)",
"Bash(gcloud run deploy * --project=acme-dev*)",
"Bash(gcloud builds submit --project=acme-dev*)",
"Bash(bq query --project_id=acme-dev*)",
"Bash(terraform -chdir=terraform/dev plan*)",
"Bash(terraform -chdir=terraform/dev apply tfplan*)"
],
"deny": [
"Bash(gcloud config configurations activate prod*)",
"Bash(gcloud run deploy * --project=acme-prod*)",
"Bash(gcloud projects delete*)",
"Bash(gcloud iam service-accounts keys create*)",
"Bash(gsutil rm -r*)", "Bash(bq rm*)",
"Bash(terraform destroy*)",
"Bash(terraform -chdir=terraform/prod*)"
]
}
}
The allow list is wide enough for real work: build, test, deploy to dev, run BigQuery queries, Terraform plan and apply on dev. The deny list stops prod activation, prod deploys, key creation, recursive bucket deletes, dataset removal, and any Terraform action against prod. Dev approved, prod requires explicit human steps, destructive primitives gated.
For multi-service repos, the Claude Code monorepo guide covers workspace conventions that keep these allowlists tractable. For deploys that need pre and post-checks, see the Claude Code deploy guide.
Hard rules and final guardrails
A short list of non-negotiable rules belongs at the bottom of every GCP CLAUDE.md.
## Hard rules
1. NEVER grant roles/owner, roles/editor, or roles/viewer to application service accounts.
2. NEVER create service account keys. Use workload identity or attached SAs.
3. NEVER deploy Cloud Run with --allow-unauthenticated unless genuinely public, with a same-line comment.
4. NEVER run gcloud, bq, or gsutil without --project explicitly set.
5. NEVER deploy to a production project without explicit user approval per deploy.
6. NEVER skip terraform plan before terraform apply.
7. NEVER query a partitioned BigQuery table without a partition filter or maximumBytesBilled cap.
8. NEVER store credentials in env vars or version control. Secret Manager only.
9. NEVER hardcode project IDs, regions, or SA emails. Use Terraform variables and env vars.
10. If a role ID, gcloud flag, or API name is uncertain, ASK before generating code.
Claude can hold ten constraints in mind and apply them consistently. Twenty become probabilistic. These ten cover the failure modes behind real GCP incidents: over-privileged SAs, leaked keys, unauthenticated services, wrong-project deploys, unbounded scans, stale credentials.
The "ask if uncertain" rule deserves emphasis. GCP renames flags and moves features between alpha, beta, and GA faster than competitors. When Claude does not know whether gcloud run services update supports a flag in the stable channel, the honest behaviour is to ask.
Building production GCP systems with Claude Code
This configuration produces a dev environment where IAM is least-privilege, every Cloud Run service has its own scoped SA, BigQuery queries are partition-aware and cost-capped, Pub/Sub topics are ordered, secrets stay in Secret Manager, and deploys are gated against production damage. Claude generates GCP code at the level of a careful senior engineer, not a junior with a fresh roles/owner binding.
Claude Code performs at the level of context you give it. Without CLAUDE.md it grants primitive roles, deploys public services, scans full tables, and stores secrets in env vars. With this configuration it follows your conventions and asks when uncertain. The best practices guide covers principles across project types, and the TypeScript guide covers patterns that make Cloud Run handlers safer. Claudify includes a GCP-specific CLAUDE.md template pre-configured for Cloud Run, IAM least-privilege, Secret Manager, BigQuery, and Terraform.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify