Use when running a small product on core Google Cloud via the gcloud CLI: a project, Cloud Run deploys, a locked-down Cloud Storage bucket, managed Cloud SQL, and least-privilege IAM wiring them together. NOT AWS (that is `aws-essentials`), NOT the CI pipeline that ships the image (that is `deployment`), NOT Postgres schema/query tuning (that is `postgresdb`).
Installs into .claude/skills of the current project.
Are you the author of Gcp Essentials?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-gcp-essentials)
---
name: gcp-essentials
description: "Use when running a small product on core Google Cloud via the gcloud CLI: a project, Cloud Run deploys, a locked-down Cloud Storage bucket, managed Cloud SQL, and least-privilege IAM wiring them together. NOT AWS (that is `aws-essentials`), NOT the CI pipeline that ships the image (that is `deployment`), NOT Postgres schema/query tuning (that is `postgresdb`)."
tags: [gcp, cloud-run, cloud-sql, cloud-storage, iam, gcloud, serverless, devops]
recommends: [aws-essentials, docker, github-actions, secure-coding, postgresdb, deployment, monitoring, backups]
origin: risco
---
# GCP essentials
Get a small product running on the core of Google Cloud — safely and cheaply — with
the `gcloud` CLI as the source of truth. The console is fine for reading; the CLI is
what you commit, review, and reproduce. Bias toward **secure-by-default and near-zero
bill**, not "every GCP service".
Four primitives carry most products, plus the project/billing scaffold under them:
- **IAM** — who can do what. Get this wrong and nothing else matters.
- **Cloud Run** — serverless containers, scale to zero.
- **Cloud Storage** — object storage (buckets).
- **Cloud SQL** — managed Postgres/MySQL.
Out of scope, route elsewhere: AWS -> [`../aws-essentials/SKILL.md`](../aws-essentials/SKILL.md).
Building/shipping the image itself -> [`../docker/SKILL.md`](../docker/SKILL.md)
/ [`../github-actions/SKILL.md`](../github-actions/SKILL.md) / [`../deployment/SKILL.md`](../deployment/SKILL.md).
Postgres schema/index/query tuning -> [`../postgresdb/SKILL.md`](../postgresdb/SKILL.md).
App-level injection/secret-handling review -> [`../secure-coding/SKILL.md`](../secure-coding/SKILL.md).
Logging/alerting/SLOs as a practice -> [`../monitoring/SKILL.md`](../monitoring/SKILL.md).
Backup strategy as a discipline -> [`../backups/SKILL.md`](../backups/SKILL.md).
One-click PaaS where you never touch IAM/VPC -> [`../vercel/SKILL.md`](../vercel/SKILL.md)
/ [`../railway/SKILL.md`](../railway/SKILL.md) / [`../render/SKILL.md`](../render/SKILL.md)
/ [`../fly-io/SKILL.md`](../fly-io/SKILL.md).
## 0. Bootstrap a project
One project per environment (e.g. `acme-prod`, `acme-staging`). Projects are the IAM
and billing boundary; mixing prod and dev in one project is how a staging credential
deletes prod data.
```bash
# Create the project and point gcloud at it
gcloud projects create acme-prod --name="Acme prod"
gcloud config set project acme-prod
gcloud config set run/region europe-west1 # set once; every run command inherits it
# Link billing (no billing = APIs 403). Find your account id first:
gcloud billing accounts list
gcloud billing projects link acme-prod --billing-account=0X0X0X-0X0X0X-0X0X0X
# Enable ONLY the APIs this product needs. Why: every enabled API widens the
# attack surface and some bill the moment they are on.
gcloud services enable \
run.googleapis.com \
sqladmin.googleapis.com \
storage.googleapis.com \
secretmanager.googleapis.com \
iam.googleapis.com
```
## 1. IAM without footguns
A binding is `member + role` on a resource. Members come in three flavours you will
actually type:
| Member type | Syntax | Use for |
|------------------|-------------------------------------|----------------------------------|
| User | `user:alice@acme.com` | a human |
| Group | `group:eng@acme.com` | a team (manage in Workspace) |
| Service account | `serviceAccount:NAME@PROJ.iam.gserviceaccount.com` | a workload identity |
Grant grammar — bind at the smallest resource that works (project here, but prefer
bucket/instance scope when the role supports it):
```bash
gcloud projects add-iam-policy-binding acme-prod \
--member="serviceAccount:api@acme-prod.iam.gserviceaccount.com" \
--role="roles/cloudsql.client"
```
Choosing a role:
| Role kind | Example | When |
|----------------|-------------------------------|-------------------------------------------------------------|
| Primitive | `roles/owner`, `roles/editor` | Almost never on a workload — project-wide, far too broad. |
| **Predefined** | `roles/storage.objectAdmin` | **Default.** Google-maintained, scoped to one service. |
| Custom | your own permission list | Only when no predefined role fits — you now own the upkeep. |
Two hard rules, each with teeth:
1. **Never run a workload as the default compute service account.** It carries
`Editor` on the whole project, so a single RCE in your container = full project
takeover. Mint a dedicated SA per service and pass it explicitly (see Cloud Run).
```bash
gcloud iam service-accounts create api-sa --display-name="api runtime"
```
2. **Never create service-account JSON keys.** A leaked key is a long-lived,
un-rotated credential. Use the *attached* SA on Cloud Run/Compute, and Workload
Identity Federation for external/CI auth (GitHub Actions). If `... keys create` is
in your runbook, the runbook is wrong.
The predefined-role catalog, WIF for keyless CI, SA impersonation, IAM Recommender and
Conditions live in [`references/iam-and-auth.md`](references/iam-and-auth.md).
## 2. Cloud Run
Minimal *safe* deploy: dedicated runtime SA, explicit region, no anonymous ingress.
```bash
gcloud run deploy api \
--image=europe-west1-docker.pkg.dev/acme-prod/app/api:1.4.0 \
--region=europe-west1 \
--service-account=api-sa@acme-prod.iam.gserviceaccount.com \
--no-allow-unauthenticated
```
- `--service-account` sets the runtime identity. Omit it and the revision runs as the
over-privileged default compute SA — the rule-1 footgun. Always pass it.
- `--no-allow-unauthenticated` keeps the service private (callers need
`roles/run.invoker`). Flip to `--allow-unauthenticated` *only* for a genuinely public
endpoint. Open by accident and you have shipped an unauthenticated API.
Production knobs:
```bash
# Cold starts hurt: pin a warm instance and boost CPU on startup.
# Default min-instances is 0 (scales to zero); default max is 100 (your cost ceiling).
gcloud run services update api --region=europe-west1 \
--min-instances=1 --cpu-boost --max-instances=20
```
Config vs secrets — **secrets never go in `--set-env-vars`**, because env vars show up
in plaintext in `describe`, logs and the console. Mount them from Secret Manager:
```bash
gcloud run deploy api --region=europe-west1 \
--service-account=api-sa@acme-prod.iam.gserviceaccount.com \
--set-env-vars="LOG_LEVEL=info" \
--set-secrets="DB_PASSWORD=db-password:latest"
```
## 3. Cloud Storage
Create buckets locked down; loosen deliberately, never the reverse.
```bash
gcloud storage buckets create gs://acme-prod-uploads \
--location=europe-west1 \
--uniform-bucket-level-access \
--public-access-prevention
```
- `--uniform-bucket-level-access` (UBLA) turns off per-object ACLs so access is *only*
IAM — one place to reason about, one place to audit. There is a 90-day window to
revert UBLA; after that it is permanent, so set it at creation.
- `--public-access-prevention` makes a public grant impossible even by mistake.
Grant access to the workload, not the world:
```bash
gcloud storage buckets add-iam-policy-binding gs://acme-prod-uploads \
--member="serviceAccount:api-sa@acme-prod.iam.gserviceaccount.com" \
--role="roles/storage.objectAdmin"
```
Need to hand a file to an anonymous browser? Use a **signed URL** (time-limited),
never `allUsers`:
```bash
gcloud storage sign-url gs://acme-prod-uploads/report.pdf --duration=15m \
--impersonate-service-account=api-sa@acme-prod.iam.gserviceaccount.com
```
`--impersonate-service-account` is not decoration. Signing needs a private key, and the
keyless model this skill mandates (attached SA, no JSON keys) hands you an ADC *token*,
not a key. The flag tells gcloud to sign via the IAM `signBlob` API instead — so the
caller must hold `roles/iam.serviceAccountTokenCreator` (which grants
`iam.serviceAccounts.signBlob`) **on `api-sa`**. Without it, the command fails or
silently wants a key file, which would reopen the rule-2 footgun. Grant it once:
```bash
gcloud iam service-accounts add-iam-policy-binding \
api-sa@acme-prod.iam.gserviceaccount.com \
--member="serviceAccount:api-sa@acme-prod.iam.gserviceaccount.com" \
--role="roles/iam.serviceAccountTokenCreator"
```
Durability one-liners:
```bash
gcloud storage buckets update gs://acme-prod-uploads --versioning # keep old versions
gcloud storage buckets update gs://acme-prod-uploads \
--lifecycle-file=lifecycle.json # auto-expire/age out
```
## 4. Cloud SQL
Create a managed Postgres with a private IP and **no** public IP — the public IP is
the part that gets scanned and brute-forced.
```bash
gcloud sql instances create acme-db \
--database-version=POSTGRES_16 \
--edition=ENTERPRISE \
--region=europe-west1 \
--tier=db-f1-micro \
--no-assign-ip \
--network=projects/acme-prod/global/networks/default
```
`--edition=ENTERPRISE` is **mandatory** here, not optional. From POSTGRES_16 up the
default edition is Enterprise *Plus*, which only runs on N2/C4A/N4 machine series — the
shared-core `db-f1-micro` is an Enterprise-only tier, so the create **fails** without
this flag. Want the cheapest box? Stay on Enterprise. Reach for Enterprise Plus only
when you actually need its dedicated cores and faster failover, and drop `--tier` for a
`--cpu`/`--memory` pair then.
Put the password in Secret Manager, not in a flag or a file:
```bash
gcloud sql users create app --instance=acme-db --password="$(openssl rand -base64 24)"
printf '%s' "$(openssl rand -base64 24)" | \
gcloud secrets create db-password --data-file=-
```
Attach the instance to Cloud Run — serverless connects over a Unix socket, **no Auth
Proxy sidecar needed**:
```bash
gcloud run deploy api --region=europe-west1 \
--service-account=api-sa@acme-prod.iam.gserviceaccount.com \
--add-cloudsql-instances=acme-prod:europe-west1:acme-db \
--set-secrets="DB_PASSWORD=db-password:latest"
# In the app, connect via the socket:
# host=/cloudsql/acme-prod:europe-west1:acme-db
```
The Cloud SQL **Auth Proxy** (short-lived certs, TLS 1.3) is for connecting from
*outside* — local dev or a non-serverless host — not for Cloud Run. Direct VPC egress vs
legacy connectors, private IP / PSC, proxy invocation and pooling are in
[`references/networking-and-sql.md`](references/networking-and-sql.md).
## 5. Wire it together
One service, one dedicated SA, exactly the roles it needs — and nothing else.
```bash
# Identity
gcloud iam service-accounts create api-sa --display-name="api runtime"
SA=api-sa@acme-prod.iam.gserviceaccount.com
# Exactly four predefined roles. No Editor, no Owner.
gcloud projects add-iam-policy-binding acme-prod \
--member="serviceAccount:$SA" --role="roles/cloudsql.client"
gcloud secrets add-iam-policy-binding db-password \
--member="serviceAccount:$SA" --role="roles/secretmanager.secretAccessor"
gcloud storage buckets add-iam-policy-binding gs://acme-prod-uploads \
--member="serviceAccount:$SA" --role="roles/storage.objectAdmin"
# Deploy with all three wired in
gcloud run deploy api --region=europe-west1 \
--image=europe-west1-docker.pkg.dev/acme-prod/app/api:1.4.0 \
--service-account="$SA" \
--no-allow-unauthenticated \
--add-cloudsql-instances=acme-prod:europe-west1:acme-db \
--set-secrets="DB_PASSWORD=db-password:latest" \
--set-env-vars="BUCKET=acme-prod-uploads"
```
Note the scoping: `cloudsql.client` is project-wide (the role needs it), but the
storage and secret grants are bound to the *specific* bucket and secret, not the
project. Grant narrow.
Copy-paste runbooks for each piece — image build and push, private container deploy,
attach SQL, mount a secret, full teardown — are in
[`references/deploy-recipes.md`](references/deploy-recipes.md).
## 6. Cost & teardown
- Cloud Run scales to zero by default — an idle service costs ~nothing. Keep
`--min-instances=0` on staging.
- Cap blast radius with `--max-instances` and a budget alert (full command in
[`references/deploy-recipes.md`](references/deploy-recipes.md)):
```bash
gcloud billing budgets create --billing-account=0X0X0X-0X0X0X-0X0X0X \
--display-name="acme-prod" --budget-amount=50 \
--threshold-rule=percent=0.9
```
- Tear down in dependency order so nothing dangles:
```bash
gcloud run services delete api --region=europe-west1
gcloud sql instances delete acme-db
gcloud storage rm --recursive gs://acme-prod-uploads
```
## Anti-patterns
| Bad | Good | Why |
|-----|------|-----|
| Deploy with no `--service-account` | Pass a dedicated per-service SA | Default compute SA has Editor; an RCE becomes project takeover |
| `gcloud iam service-accounts keys create key.json` | Attached SA + Workload Identity Federation | JSON keys are long-lived, leak, and are rarely rotated |
| `--role=roles/editor` on a workload SA | Scoped predefined roles (`cloudsql.client`, …) | Primitive roles grant far more than the service needs |
| Bucket public via `allUsers` | Signed URL via `--impersonate-service-account` (+ Token Creator) | A public bucket is a data leak; keyless signing needs `signBlob`, not a key file |
| Bucket created without UBLA/PAP | `--uniform-bucket-level-access --public-access-prevention` at create | ACLs sprawl; PAP blocks accidental public grants |
| Cloud SQL with public IP open to `0.0.0.0/0` | `--no-assign-ip` + private IP / Auth Proxy | Public DB IPs get scanned and brute-forced |
| Secrets in `--set-env-vars` | `--set-secrets` from Secret Manager | Env vars are plaintext in `describe`, logs, console |
| `gcloud services enable` everything | Enable only the APIs you use | Each API widens attack surface; some bill on enable |
| No `--min-instances` on prod, then blame cold starts | `--min-instances=1 --cpu-boost` on prod | Scale-to-zero is the cause; pin a warm instance |
| Auth Proxy sidecar on Cloud Run | `--add-cloudsql-instances` + `/cloudsql/...` socket | Serverless connects natively; the proxy is for outside-VPC |
## Verify
`scripts/verify.sh` is an offline static linter (no GCP calls, no network) over files
that contain `gcloud` command blocks. It flags the unsafe patterns above: JSON key
creation, `roles/owner|roles/editor` bound to a service account, bucket creates missing
UBLA/PAP, Cloud SQL public IP without private IP, and Cloud Run deploys missing
`--service-account`.
```bash
bash scripts/verify.sh path/to/runbook.sh # one file
bash scripts/verify.sh path/to/dir/ # recurse a directory
```
It prints `PASS`/`FAIL` per check and exits nonzero on any FAIL. An empty or
clean target passes (exit 0).