Tech

The GCP Deployment Runbook: Every Command, Every Fix

The exact commands, in order, including every failure and fix, for deploying a FastAPI + React app to Cloud Run.

Part 6 of 7 The LLM Playground

The GCP Deployment Runbook: Every Command, Every Fix

Post 6 of 7. Post 5 was the reflective account of what production demanded. This post is the practical companion, the exact commands, in order, including the failures and fixes, so you can deploy your own FastAPI + React app to Cloud Run without rediscovering each error from scratch.


This is a runbook, not an essay, headers you can jump to, commands you can copy. It assumes a FastAPI backend and a containerizable frontend (SPA or SSR), both with working local Dockerfiles, and a GCP project with billing linked. Every failure documented here actually happened during this project's deployment; the fix is given immediately after the error, not as a separate troubleshooting appendix.

Prerequisites

  • gcloud CLI installed and authenticated (gcloud auth login)
  • A GCP project with billing enabled, project ID in hand
  • Docker Desktop installed and actually running (see Pitfall 1, this is a bigger deal than it sounds)
  • Backend and frontend each with a working local Dockerfile (test with docker build + docker run before touching GCP at all)

Part 1, One-time project setup

gcloud config set project YOUR_PROJECT_ID

gcloud services enable \
  run.googleapis.com \
  artifactregistry.googleapis.com \
  cloudbuild.googleapis.com \
  secretmanager.googleapis.com

Create an Artifact Registry repo to hold your images, pick one region and use it consistently for everything downstream (mixing regions is Pitfall 2):

gcloud artifacts repositories create YOUR_REPO_NAME \
  --repository-format=docker \
  --location=YOUR_REGION

gcloud auth configure-docker YOUR_REGION-docker.pkg.dev -q

Pitfall 1, Docker Desktop silently half-started. If gcloud auth configure-docker or docker info hangs with zero output, Docker Desktop is very likely open but not actually running (a common cause: an unresolved sign-in prompt blocking full daemon startup). Quit it completely, reopen, wait for the whale icon to go fully steady, then confirm with docker info before retrying anything Docker-related. Note this only affects local Docker commands, gcloud builds submit, covered next, builds on Google's infrastructure and doesn't need a local daemon at all.

Pitfall 2, region mismatch. gcloud auth configure-docker needs to be run once per region you'll push to. If you create your Artifact Registry repo in us-central1 but run configure-docker against us-west1, pushes to the actual repo will fail with credential errors that look unrelated to the actual cause. Pick one region at the start and grep your own commands for it before running them.

Part 2, Secrets

echo -n "your-actual-secret-value" | \
  gcloud secrets create YOUR_SECRET_NAME --data-file=-

If the secret already exists (re-running this after an earlier partial attempt): gcloud secrets versions add YOUR_SECRET_NAME --data-file=- instead.

Part 3, Backend: build, push, deploy

cd path/to/backend  # must contain Dockerfile and requirements.txt

gcloud builds submit \
  --tag YOUR_REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO_NAME/backend:v1

Pitfall 3, Apple Silicon and the x86 mismatch. Never docker build locally and push that image straight to Cloud Run if you're on an M-series Mac, it's built for ARM, Cloud Run runs x86, and the failure mode is a crash after a successful-looking deploy, which is a much more confusing place to debug from. gcloud builds submit sidesteps this entirely by building on Google's infrastructure, use it for anything you intend to actually deploy.

gcloud run deploy YOUR_BACKEND_SERVICE_NAME \
  --image YOUR_REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO_NAME/backend:v1 \
  --region YOUR_REGION \
  --memory 8Gi \
  --cpu 4 \
  --timeout 600 \
  --concurrency 4 \
  --max-instances 2 \
  --allow-unauthenticated \
  --set-secrets YOUR_ENV_VAR_NAME=YOUR_SECRET_NAME:latest

Pitfall 4, secret access denied. First deploy with --set-secrets will very likely fail with Permission denied on secret ... Secret Manager Secret Accessor. The default compute service account has no implicit access to any secret, ever, grant it explicitly:

gcloud secrets add-iam-policy-binding YOUR_SECRET_NAME \
  --member="serviceAccount:PROJECT_NUMBER-compute@developer.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"

(Find PROJECT_NUMBER with gcloud projects describe YOUR_PROJECT_ID --format="value(projectNumber)", or read it directly out of the error message, it's usually embedded in the failing resource path.) Retry the deploy after granting.

A successful deploy prints a Service URL:, save it, everything downstream needs it.

Part 4, Frontend: the build-arg wrinkle

If your frontend needs the backend URL baked in at build time (standard for a Vite/SSR app calling a fixed API), a plain --tag build can't pass that value through, you need an explicit config file:

# cloudbuild.yaml, in the frontend repo root
steps:
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'build'
      - '--build-arg'
      - 'YOUR_BUILD_ARG_NAME=https://your-backend-service-url'
      - '-t'
      - 'YOUR_REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO_NAME/frontend:v1'
      - '.'
images:
  - 'YOUR_REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO_NAME/frontend:v1'
gcloud builds submit --config cloudbuild.yaml .

gcloud run deploy YOUR_FRONTEND_SERVICE_NAME \
  --image YOUR_REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO_NAME/frontend:v1 \
  --region YOUR_REGION \
  --memory 512Mi \
  --cpu 1 \
  --allow-unauthenticated \
  --max-instances 2

Pitfall 5, check what your frontend actually is before writing its Dockerfile. A dev server (npm run dev) makes SPAs, SSR apps, and edge-targeted builds look identical. They are not. An nginx-serves-static-files Dockerfile only works for a true SPA; an SSR framework (Next.js, TanStack Start, Remix, etc.) needs its own Node process running in the container. Check your framework's build output before assuming, npm run build and look at what actually gets produced, not what you expect to be produced.

Part 5, Close the loop: CORS

Your backend was almost certainly deployed with permissive CORS (allow_origins=["*"]) to unblock initial testing. Now that the frontend has a stable URL, lock it down and redeploy:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://your-actual-frontend-url"],
    allow_methods=["*"],
    allow_headers=["*"],
)

Rebuild and redeploy the backend (Part 3, incremented tag), then click through the live frontend end-to-end to confirm nothing broke, an over-strict origin match (trailing slash, http vs. https) is a common way to silently re-break everything that just worked.

Part 6, CI/CD: auto-deploy on push

A manual gcloud builds submit + gcloud run deploy is a one-time deploy, not a pipeline. To get real "push to main → auto-deployed":

1. Connect the repo (one-time, requires the console, this specific step is OAuth-based and can't be scripted):

https://console.cloud.google.com/cloud-build/triggers/connect

2. Grant the Cloud Build service account permission to deploy, this is the step most likely to be skipped, and skipping it produces failures much later, mid-pipeline, that look unrelated to IAM:

gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
  --member="serviceAccount:PROJECT_NUMBER@cloudbuild.gserviceaccount.com" \
  --role="roles/run.admin" \
  --condition=None

gcloud iam service-accounts add-iam-policy-binding \
  PROJECT_NUMBER-compute@developer.gserviceaccount.com \
  --member="serviceAccount:PROJECT_NUMBER@cloudbuild.gserviceaccount.com" \
  --role="roles/iam.serviceAccountUser" \
  --condition=None

gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
  --member="serviceAccount:PROJECT_NUMBER@cloudbuild.gserviceaccount.com" \
  --role="roles/artifactregistry.reader" \
  --condition=None

Pitfall 6, three permissions, not one. Deploying to Cloud Run via an automated pipeline needs all three of the above: permission to issue the deploy (run.admin), permission to act as the runtime identity the deployed service will use (iam.serviceAccountUser), and permission to pull whatever container images the pipeline's own build steps depend on (artifactregistry.reader). Missing any one produces a distinct, differently-worded failure at a different pipeline stage, they don't fail together, so fix them one at a time as each error surfaces.

3. Write a cloudbuild.yaml that builds AND deploys, not just builds, a trigger firing a build-only config will rebuild your image forever without ever updating the running service:

steps:
  - name: 'gcr.io/cloud-builders/docker'
    args: ['build', '-t', 'YOUR_REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO_NAME/backend:$SHORT_SHA', '.']

  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'YOUR_REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO_NAME/backend:$SHORT_SHA']

  - name: 'gcr.io/cloud-builders/gcloud'
    args:
      - 'run'
      - 'deploy'
      - 'YOUR_BACKEND_SERVICE_NAME'
      - '--image=YOUR_REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO_NAME/backend:$SHORT_SHA'
      - '--region=YOUR_REGION'
      # ...same flags as your manual deploy...

images:
  - 'YOUR_REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO_NAME/backend:$SHORT_SHA'

options:
  logging: CLOUD_LOGGING_ONLY

Two details that aren't obvious until you hit them:

  • $SHORT_SHA, not a hardcoded tag. This is a Cloud Build substitution that resolves to the triggering commit's short hash, every automated deploy gets a uniquely traceable image instead of silently overwriting v1 forever.
  • options: logging: CLOUD_LOGGING_ONLY is required the moment you specify a custom (non-default) service account for the build, which a trigger typically does. Omitting it fails with an error about needing a logs bucket, the fix is this one line, not actually creating a bucket.

Pitfall 7, the deploy step's own builder image can be wrong. gcr.io/google-cloud-sdk/slim, a commonly-referenced image for running gcloud commands inside a build step, returned artifactregistry.repositories.downloadArtifacts denied even after all three IAM grants above were in place, its access path didn't resolve the way the docker builder images' did. Switching the deploy step to gcr.io/cloud-builders/gcloud (same registry namespace as the already-working docker builder steps) resolved it immediately, without any further IAM changes. If a specific builder image keeps failing after permissions look correct, trying a different image in the same family is often faster than continuing to chase that image's specific access grant.

4. Create the trigger. If your repo is connected via the newer Developer Connect flow rather than the classic GitHub App, gcloud builds triggers create github --repo-name=... --repo-owner=... will fail with a generic INVALID_ARGUMENT. Diagnose which connection type you have and create the trigger through the console instead, its form auto-detects the connection type and avoids guessing at CLI flag shapes:

https://console.cloud.google.com/cloud-build/triggers

Set: event = push to branch, branch pattern = ^main$, build config = cloudbuild.yaml, service account = the one you granted permissions to above (not a different default).

5. Verify:

git commit --allow-empty -m "trigger test"
git push origin main

gcloud builds list --limit=5

A build should appear automatically, with no gcloud builds submit run by you.

Part 7, Two production-shaping decisions worth making deliberately

Where should model weights live? Lazy-loading models on first request avoids a large Docker image but means the first request to any given model, on any given fresh instance, pays a real cost, network download if not cached locally, deserialization either way. Baking weights into the image at build time trades a slower, larger build for fast, predictable requests. For a small number of small models, baking in is very likely worth it.

When should models load, at request time, or at startup? Even with weights already on disk in the image, from_pretrained() still costs real CPU time to deserialize into memory, and by default that cost lands on whichever request is first to touch a given model. An @app.on_event("startup") hook that eagerly loads everything moves that cost to instance boot, invisible to users, since Cloud Run won't route traffic to an instance until it's healthy, at the price of a slower, more resource-intensive startup, which matters more the more instances you expect to spin up under load.

Quick-reference: pitfalls by symptom

SymptomCauseFix
docker info / configure-docker hangsDocker Desktop not fully startedFull quit + restart, confirm with docker info before retrying
Push fails with unrelated-looking credential errorconfigure-docker run against wrong regionRe-run for the region your repo actually lives in
Deploy crashes after a clean-looking buildImage built for ARM, deployed to x86Use gcloud builds submit, not local docker build, for anything you deploy
Permission denied on secret ...No IAM grant for the runtime service accountsecretmanager.secretAccessor, scoped to that secret
Frontend builds but renders nothing / crashesSSR framework misidentified as static SPACheck actual npm run build output before writing the Dockerfile
Trigger fires but service never updatescloudbuild.yaml builds but doesn't deployAdd a gcloud run deploy step to the config
INVALID_ARGUMENT creating a trigger via CLIRepo connected via Developer Connect, not classic GitHub AppCreate the trigger via console instead
downloadArtifacts denied, permissions look correctSpecific builder image's own access pathSwap to a working sibling image in the same builder family
Build fails needing a logs bucketCustom service account requires explicit logging configoptions: logging: CLOUD_LOGGING_ONLY in cloudbuild.yaml
Same model reloads repeatedly under light trafficMultiple Cloud Run instances, each loading independentlyConfirm nothing loads at import time outside your caching layer; consider eager-loading at startup

Try it yourself: every command above ran, in this order, against a real GCP project during this series' deployment, the full source, Dockerfiles, and cloudbuild.yaml files referenced here are in github.com/lovable-api/llm-playground.

#gcp#deployment#runbook