From gcloud-tools
Package and prepare a project for deployment to Google Cloud Run. Generates or updates Dockerfile, cloudbuild.yaml, and nginx.conf.template files for deploying static frontend apps (Vite/React/Next.js static export) or Node.js backends to Cloud Run via Cloud Build. Also covers full GCP project setup, IAM permissions, billing, custom domain mapping, and troubleshooting.
How this skill is triggered — by the user, by Claude, or both
Slash command
/gcloud-tools:gcloud-deployThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are helping the user package their project for deployment to **Google Cloud Run** via **Cloud Build**.
You are helping the user package their project for deployment to Google Cloud Run via Cloud Build.
/gcloud-tools:gcloud-deploy
/gcloud-tools:gcloud-deploy my-service-name
/gcloud-tools:gcloud-deploy dry-run
When to use: When you want to deploy a project to Cloud Run. Handles Dockerfile, cloudbuild.yaml, nginx config, IAM permissions, custom domains, and troubleshooting.
Supported project types:
What you'll need to provide:
What you get back: Generated deployment files (Dockerfile, cloudbuild.yaml, nginx.conf.template, .dockerignore) plus a step-by-step deployment guide.
$ARGUMENTS — Optional flags or context, e.g. a service name, region, or "dry-run" to only preview files.
package.json (or equivalent) to determine:
npm run build, bun build)bun.lockb (bun), pnpm-lock.yaml (pnpm), yarn.lock (yarn), or package-lock.json (npm)vite.config.* for build.outDir (default dist), or next.config.* for distDirexpress, fastify, @hono/node-server in dependencies, or a start script that runs a server)Dockerfile, cloudbuild.yaml, nginx.conf.template, .dockerignoreimport.meta.env.VITE_* — grep for VITE_ in src/process.env.NEXT_PUBLIC_* — grep for NEXT_PUBLIC_ in src/ and app/.env, .env.example, .env.local files for the canonical list of variable namesGEMINI_API_KEY is only used in server-side code (e.g. Convex actions, Express routes), do NOT include it as a build-time arg. Only include env vars that are actually embedded in the client bundle (e.g. VITE_* vars referenced via import.meta.env).vite.config.* for any define blocks that inject env vars — if they reference server-side-only keys, flag this as a cleanup opportunity.vite.config.* or next.config.* to confirm the frameworkStatic SPA (Vite, CRA, static Next.js export):
node:20-slim builder → nginx:alpine server/etc/nginx/templates/default.conf.template — the nginx:alpine image auto-substitutes ${PORT} from the environment on startuptry_files $uri $uri/ /index.html for client-side routingnginx:alpine image handles startupNode.js server (Express, Fastify, Next.js SSR):
node:20-slim runtimeprocess.env.PORT["node", "server.js"] or equivalentAsk the user for any values you can't infer. Present sensible defaults and let them confirm or override:
gen-lang-client-* (from Google AI Studio) often have org policy restrictions that block public access.us-west1) — this is the registry regionaustralia-southeast1)name field)cloud-run-source-deploy)VITE_* or NEXT_PUBLIC_* vars that are actually used client-side--set-env-varsgcloud run domain-mappings — it fails with 501 UNIMPLEMENTED in many regions.Important distinction — build-time vs runtime vars:
--build-arg) are embedded into the static JS bundle during npm run build. They cannot be changed without rebuilding. Use for VITE_* / NEXT_PUBLIC_* vars.--set-env-vars) are available to the running container as process.env.*. Use for server-side secrets and config. For static SPAs, these are only useful if nginx or a sidecar reads them.If deployment files already exist, diff them against what you would generate and ask the user before overwriting. Highlight what changed and why.
Create or update these files in the project root:
.dockerignorenode_modules
.git
.env*
dist
build
*.md
.claude
DockerfileFor static SPA (Vite/React example):
# Stage 1: Build the frontend
FROM node:20-slim AS builder
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm ci
# Copy the rest of the source code
COPY . .
# Build-time env vars — Vite embeds these into the static bundle during build.
# Each ARG must have a corresponding --build-arg in cloudbuild.yaml
# and a substitution variable (prefixed with _) in the Cloud Build trigger.
ARG VITE_CONVEX_URL
ENV VITE_CONVEX_URL=$VITE_CONVEX_URL
# Build the frontend (outputs to /app/dist by default)
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy the built static assets from the builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# nginx:alpine auto-processes *.template files in /etc/nginx/templates/
# on startup, substituting environment variables like $PORT.
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
# Cloud Run sets PORT=8080 by default
ENV PORT=8080
EXPOSE 8080
# No CMD needed — the base nginx image handles startup
Adapt the template:
VITE_* ARGs with actual env vars discovered in the projectnode:20-slim with oven/bun:1 and npm ci with bun install --frozen-lockfileRUN corepack enable before install, use pnpm install --frozen-lockfiledist, update the COPY path accordinglynginx.conf.template (static SPA only)server {
listen ${PORT};
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Error pages
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
Note: ${PORT} is substituted by nginx's envsubst on startup. $uri is a native nginx variable — do NOT wrap it in ${} or it will break.
cloudbuild.yamlIMPORTANT: Use ${_TAG} with a default of latest instead of $COMMIT_SHA. $COMMIT_SHA is only available when triggered by a repo event (push/PR). For manual gcloud builds submit, it will be empty and cause an "invalid image name" error.
steps:
# Build the container image using Dockerfile and pass build arguments
- name: 'gcr.io/cloud-builders/docker'
args:
- 'build'
- '-t'
- 'REGION-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/SERVICE_NAME:${_TAG}'
# Pass the secrets/variables directly into the Docker build
- '--build-arg'
- 'VITE_CONVEX_URL=$_VITE_CONVEX_URL'
- '.'
# Push the container image to Artifact Registry
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'REGION-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/SERVICE_NAME:${_TAG}']
# Deploy container image to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- 'SERVICE_NAME'
- '--image'
- 'REGION-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/SERVICE_NAME:${_TAG}'
- '--region'
- 'DEPLOY_REGION'
# Remove --allow-unauthenticated if the app requires auth
- '--allow-unauthenticated'
# Substitution variables — set these in the Cloud Build trigger settings.
# Cloud Build provides $PROJECT_ID automatically.
# User-defined substitutions must be prefixed with _.
# _TAG defaults to 'latest' for manual builds; override with $COMMIT_SHA in triggers.
substitutions:
_VITE_CONVEX_URL: ""
_TAG: 'latest'
images:
- 'REGION-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/SERVICE_NAME:${_TAG}'
options:
logging: CLOUD_LOGGING_ONLY
Adapt the template:
REGION with the actual Artifact Registry region (e.g. australia-southeast1)DEPLOY_REGION with the Cloud Run region (e.g. australia-southeast1)SERVICE_NAME with the actual service name--build-arg entries with the actual build-time env vars--set-env-vars line before --allow-unauthenticatedsubstitutions: block must match what's referenced in the stepsAfter generating files, check if the user needs help setting up GCP. Include these steps:
# Create project
gcloud projects create PROJECT_NAME --name="Project Display Name"
# Set as active
gcloud config set project PROJECT_NAME
# Link billing (required for Cloud Run, Cloud Build, Artifact Registry)
gcloud billing accounts list
gcloud billing projects link PROJECT_NAME --billing-account=ACCOUNT_ID
# Enable required APIs (include compute if custom domain is needed)
gcloud services enable cloudbuild.googleapis.com run.googleapis.com artifactregistry.googleapis.com compute.googleapis.com
The default Compute Engine service account ([email protected]) needs these roles. Grant all of these upfront to avoid build failures:
# Get the project number
PROJECT_NUMBER=$(gcloud projects describe PROJECT_NAME --format="value(projectNumber)")
# Storage access (for uploading build source)
gcloud projects add-iam-policy-binding PROJECT_NAME --member="serviceAccount:${PROJECT_NUMBER}[email protected]" --role="roles/storage.admin"
# Artifact Registry access (for pushing images)
gcloud projects add-iam-policy-binding PROJECT_NAME --member="serviceAccount:${PROJECT_NUMBER}[email protected]" --role="roles/artifactregistry.writer"
# Cloud Run deploy access
gcloud projects add-iam-policy-binding PROJECT_NAME --member="serviceAccount:${PROJECT_NUMBER}[email protected]" --role="roles/run.admin"
# Service account user (required to deploy to Cloud Run)
gcloud iam service-accounts add-iam-policy-binding ${PROJECT_NUMBER}[email protected] --member="serviceAccount:${PROJECT_NUMBER}[email protected]" --role="roles/iam.serviceAccountUser" --project=PROJECT_NAME
# Logging (to see build logs)
gcloud projects add-iam-policy-binding PROJECT_NAME --member="serviceAccount:${PROJECT_NUMBER}[email protected]" --role="roles/logging.logWriter"
gcloud artifacts repositories create cloud-run-source-deploy --repository-format=docker --location=REGION
After deploying, to allow unauthenticated access:
gcloud run services add-iam-policy-binding SERVICE_NAME --region=DEPLOY_REGION --member="allUsers" --role="roles/run.invoker"
If you get org policy errors (FAILED_PRECONDITION: One or more users named in the policy do not belong to a permitted customer):
iam.allowedPolicyMemberDomains constraintgcloud resource-manager org-policies disable-enforce iam.allowedPolicyMemberDomains --project=PROJECT_NAME
Then retry the IAM binding.gcloud run services add-iam-policy-binding SERVICE_NAME --region=DEPLOY_REGION --member="domain:yourdomain.com" --role="roles/run.invoker"
gen-lang-client-* (from Google AI Studio) often have org policies you cannot change. Create your own project instead.After generating files, print a clear summary:
## Deployment files ready
**Service:** <service-name>
**Project:** <project-id>
**Artifact Registry:** <region>-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/<service-name>
**Deploy region:** <deploy-region>
**Strategy:** Static SPA (Vite + Nginx) / Node.js server
### Files created/updated:
- `Dockerfile` — multi-stage build (node:20-slim → nginx:alpine)
- `cloudbuild.yaml` — build, push, deploy pipeline
- `nginx.conf.template` — SPA routing config
- `.dockerignore` — excludes node_modules, .git, .env files
### Build-time variables (set in Cloud Build trigger → Substitution variables):
- `_VITE_CONVEX_URL` → passed as --build-arg, embedded in JS bundle
### Next steps:
1. Set up GCP project (if new) — see setup guide above
2. Grant IAM permissions to build service account
3. Create Artifact Registry repo
4. Submit build:
gcloud builds submit --config cloudbuild.yaml
5. Enable public access (if needed)
If the user wants to point a custom domain to their Cloud Run service, set up a Global HTTPS Load Balancer. Do NOT use gcloud run domain-mappings — it is not supported in many regions (e.g. australia-southeast1 returns 501 UNIMPLEMENTED).
gcloud services enable compute.googleapis.com --project=PROJECT_NAME
Important: After enabling, wait ~30 seconds before running compute commands — the API needs time to propagate. If you get SERVICE_DISABLED errors immediately after enabling, retry after a delay.
Run these commands in order. Each must complete before the next.
# 1. Create a serverless Network Endpoint Group (NEG) pointing to the Cloud Run service
gcloud compute network-endpoint-groups create SERVICE_NAME-neg --region=DEPLOY_REGION --network-endpoint-type=serverless --cloud-run-service=SERVICE_NAME --project=PROJECT_NAME
# 2. Create a backend service
gcloud compute backend-services create SERVICE_NAME-backend --global --project=PROJECT_NAME
# 3. Add the NEG to the backend service
gcloud compute backend-services add-backend SERVICE_NAME-backend --global --network-endpoint-group=SERVICE_NAME-neg --network-endpoint-group-region=DEPLOY_REGION --project=PROJECT_NAME
# 4. Create a Google-managed SSL certificate for the domain
gcloud compute ssl-certificates create SERVICE_NAME-cert --domains=CUSTOM_DOMAIN --global --project=PROJECT_NAME
# 5. Reserve a global static IP address
gcloud compute addresses create SERVICE_NAME-ip --global --project=PROJECT_NAME
# 6. Get the IP address (user needs this for DNS)
gcloud compute addresses describe SERVICE_NAME-ip --global --project=PROJECT_NAME --format="value(address)"
# 7. Create a URL map routing all traffic to the backend
gcloud compute url-maps create SERVICE_NAME-urlmap --default-service=SERVICE_NAME-backend --global --project=PROJECT_NAME
# 8. Create the HTTPS target proxy
gcloud compute target-https-proxies create SERVICE_NAME-https-proxy --ssl-certificates=SERVICE_NAME-cert --url-map=SERVICE_NAME-urlmap --global --project=PROJECT_NAME
# 9. Create the HTTPS forwarding rule (port 443)
gcloud compute forwarding-rules create SERVICE_NAME-https-rule --global --target-https-proxy=SERVICE_NAME-https-proxy --address=SERVICE_NAME-ip --ports=443 --project=PROJECT_NAME
# 1. Create a redirect-only URL map (write to temp file first since stdin doesn't work on Windows)
echo 'name: SERVICE_NAME-http-redirect
defaultUrlRedirect:
redirectResponseCode: MOVED_PERMANENTLY_DEFAULT
httpsRedirect: true' > /tmp/http-redirect.yaml
gcloud compute url-maps import SERVICE_NAME-http-redirect --global --project=PROJECT_NAME --source=/tmp/http-redirect.yaml
# 2. Create HTTP target proxy
gcloud compute target-http-proxies create SERVICE_NAME-http-proxy --url-map=SERVICE_NAME-http-redirect --global --project=PROJECT_NAME
# 3. Create HTTP forwarding rule (port 80)
gcloud compute forwarding-rules create SERVICE_NAME-http-rule --global --target-http-proxy=SERVICE_NAME-http-proxy --address=SERVICE_NAME-ip --ports=80 --project=PROJECT_NAME
Tell the user to update their domain's DNS records at their registrar:
| Type | Host | Value |
|---|---|---|
| A Record | @ | <STATIC_IP from step 6> |
If they have old A records (e.g. 216.239.x.x from a previous Google domain mapping), those must be removed and replaced with the new IP.
Optional records to keep/add:
www → ghs.googlehosted.com. (for www subdomain)The managed SSL certificate starts in PROVISIONING status. It will become ACTIVE once:
Check status with:
gcloud compute ssl-certificates describe SERVICE_NAME-cert --global --project=PROJECT_NAME --format="yaml(managed)"
Status progression: PROVISIONING → ACTIVE (success) or FAILED_NOT_VISIBLE (DNS not yet propagated — wait and it will retry automatically).
Verify DNS is resolving correctly:
nslookup CUSTOM_DOMAIN 8.8.8.8
To redeploy after code changes:
# Commit and push changes to git
git add -A && git commit -m "update" && git push
# Submit build (from project root)
gcloud builds submit --config cloudbuild.yaml
Or set up a Cloud Build trigger for auto-deploy on push.
_)$PROJECT_ID — Cloud Build provides this automatically, do not make it a substitution var${_TAG} instead of $COMMIT_SHA — COMMIT_SHA is empty on manual buildsVITE_EXAMPLE_VARbun, use oven/bun:1 as the builder image and bun install --frozen-lockfilepnpm, enable corepack and use pnpm install --frozen-lockfile$uri is a native nginx variable (no braces), while ${PORT} uses braces for envsubstgcloud run domain-mappings — it returns 501 UNIMPLEMENTED in many regions (e.g. australia-southeast1). Always use a Global HTTPS Load Balancer for custom domains.gcloud services enable, wait ~30 seconds before using it — the propagation delay causes SERVICE_DISABLED errors if you proceed immediatelygcloud compute url-maps import --source=/dev/stdin does NOT work. Write YAML to a temp file and use --source=/tmp/file.yaml insteadiam.allowedPolicyMemberDomains blocks allUsers bindings AND the user lacks org-admin rights to disable it, advise using the Cloud Run Console UI (Security tab) which can sometimes bypass the restriction| Error | Cause | Fix |
|---|---|---|
invalid image name "...:"" | $COMMIT_SHA empty on manual build | Use ${_TAG} with default latest |
does not have storage.objects.get access | Build SA missing storage role | Grant roles/storage.admin |
step exited with non-zero status: 1 on push step | Build SA missing AR write role | Grant roles/artifactregistry.writer |
| Deploy step fails silently | Build SA missing run.admin or iam.serviceAccountUser | Grant both roles |
One or more users named in the policy do not belong to a permitted customer | Org policy blocks allUsers/allAuthenticatedUsers | Disable org policy constraint or use domain-restricted binding |
Billing account not found | New project without billing | Link billing account |
API not enabled | Required APIs not turned on | Enable cloudbuild, run, artifactregistry APIs |
Error: Forbidden in browser | Cloud Run requires auth but no login flow | Set allUsers invoker role, or disable org policy first |
Creating domain mappings is not allowed in REGION (501) | gcloud run domain-mappings not supported in that region | Use Global HTTPS Load Balancer instead (see Section 7) |
Compute Engine API has not been used after enabling | API propagation delay | Wait 30 seconds after gcloud services enable compute.googleapis.com then retry |
SSL cert stuck in PROVISIONING / FAILED_NOT_VISIBLE | DNS not yet pointing to load balancer IP | Verify A record with nslookup DOMAIN 8.8.8.8, wait 15-60 min after DNS update |
gcloud compute url-maps import fails with stdin error | Windows/Git Bash doesn't support /dev/stdin | Write YAML to a temp file first, then pass with --source=/tmp/file.yaml |
Use Google Cloud Secret Manager for tokens and credentials:
# Store a secret (paste value then Ctrl+D)
gcloud secrets create SECRET_NAME --data-file=-
# Retrieve a secret
gcloud secrets versions access latest --secret=SECRET_NAME
# List all secrets
gcloud secrets list
Guides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub mercurial-weasel/bh-ops-claude-plugins --plugin gcloud-tools