Skip to content

Deploy on Google Cloud Run

Cloud Run runs your container, scales it, and bills per request — a good fit for skein, with one sharp edge: by default Cloud Run throttles a container's CPU to near-zero between requests, and skein's background runs do their work after the request that created them has returned. Get that setting wrong and inline runs work perfectly while background runs mysteriously never finish.

Everything platform-agnostic — environment variables, pool sizing, probes, scaling caveats — is in deploy.md.

Before you start

You need the gcloud CLI authenticated, a project with billing enabled, and Docker. Enable the APIs once:

bash
export PROJECT_ID=your-project REGION=us-central1 REPO=skein SERVICE=skein-app
gcloud config set project $PROJECT_ID
gcloud services enable run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com

1. Build and push the image

bash
# Cloud Run runs x86. skein build doesn't pass --platform, so set this on Apple Silicon.
export DOCKER_DEFAULT_PLATFORM=linux/amd64
export IMAGE=$REGION-docker.pkg.dev/$PROJECT_ID/$REPO/$SERVICE:v1

gcloud artifacts repositories create $REPO --repository-format=docker --location=$REGION
gcloud auth configure-docker $REGION-docker.pkg.dev

skein build -t $SERVICE
docker tag $SERVICE $IMAGE
docker push $IMAGE

2. Provision Postgres + Redis

Anything reachable works. Two shapes are common:

Managed outside GCP (Neon, Supabase, Upstash, Redis Cloud) — simplest, no VPC needed, and the connection strings work as-is. Use the direct Postgres endpoint, not a pooled one (why).

GCP-native — Cloud SQL and Memorystore:

  • Cloud SQL attaches over a Unix socket, which needs no TLS configuration. POSTGRES_URI is handed straight to pg, so the socket form works:
    text
    postgresql://USER:PASSWORD@/DATABASE?host=/cloudsql/PROJECT:REGION:INSTANCE
    Add --add-cloudsql-instances=PROJECT:REGION:INSTANCE to the deploy below.
  • Memorystore is only reachable from inside your VPC, so the service needs Direct VPC egress or a Serverless VPC Access connector (--network/--subnet, or --vpc-connector).

Store both URIs in Secret Manager rather than passing them as plain environment variables — gcloud run services describe prints env vars in cleartext:

bash
printf '%s' "$POSTGRES_URI" | gcloud secrets create skein-postgres-uri --data-file=-
printf '%s' "$REDIS_URI"    | gcloud secrets create skein-redis-uri --data-file=-

Grant the service's runtime service account roles/secretmanager.secretAccessor.

3. Deploy

bash
gcloud run deploy $SERVICE \
  --image=$IMAGE \
  --region=$REGION \
  --port=8123 \
  --set-secrets=POSTGRES_URI=skein-postgres-uri:latest,REDIS_URI=skein-redis-uri:latest \
  --set-env-vars=PG_POOL_MAX=5,SKEIN_RUN_CONCURRENCY=5 \
  --no-cpu-throttling \
  --min-instances=1 \
  --max-instances=3 \
  --cpu=1 --memory=1Gi \
  --timeout=3600 \
  --no-allow-unauthenticated

The flags that matter, and why:

FlagWhy
--port=8123Cloud Run injects PORT, and skein binds it. This tells Cloud Run which port to send traffic to.
--no-cpu-throttling + --min-instances=1Required for background runs. See below.
--timeout=3600The ceiling on a streaming SSE run; skein imposes none of its own.
--no-allow-unauthenticatedskein's auth is off by default — see the warning.
PG_POOL_MAX=5Two pools per instance × instances vs. your database's cap (budget).

Cloud Run's built-in health checking uses the container port; you can also declare an explicit startup probe on GET /ok with a ~30s failure budget, since boot runs migrations before listening.

4. Verify

With --no-allow-unauthenticated, reach the service through an authenticated tunnel rather than opening it up:

bash
gcloud run services proxy $SERVICE --region=$REGION --port=8123

Then run the verification sequence against http://localhost:8123. Step 4 — the background run — is the one that proves the CPU settings are right.

Cloud Run caveats

Background runs need CPU outside requests

This is the big one. POST /threads/{id}/runs enqueues a run and responds immediately; the worker executes it afterwards. Under Cloud Run's default CPU-allocated-during-requests model, the instance's CPU is throttled to near-zero the moment the response is sent, so that run stops making progress — and with --min-instances=0 the instance may be shut down entirely.

Set --no-cpu-throttling (CPU always allocated) and --min-instances=1. This costs more — you're paying for an always-on vCPU rather than per-request — but it's what makes background runs, webhooks and cron-shaped work behave.

If you only use inline runs (/runs/wait, /runs/stream), the work happens during the request and the defaults are fine. Scale to zero freely.

Request timeout vs. streaming

Default 300s, max 3600s. A long SSE run is a long request. skein sends no heartbeat frame, so a stream that goes quiet still counts against the timeout — set --timeout to cover your worst case. Don't put Cloud CDN in front of the streaming routes; it buffers.

Connections vs. autoscaling

Each instance opens two Postgres pools, so a burst to --max-instances=10 with PG_POOL_MAX=10 wants 200 connections. Cap --max-instances, keep PG_POOL_MAX small, or front Cloud SQL with the Auth Proxy or a pooler.

Shutdown

Cloud Run sends SIGTERM and SIGKILLs 10 seconds later by default. skein drains in-flight runs for 5s, aborts the rest so they land in a terminal status, and exits — comfortably inside that window. If you raise SKEIN_SHUTDOWN_GRACE_MS, raise Cloud Run's termination grace period to match, or you'll be killed mid-drain.

Note that Cloud Run has no init process. The image is fine as-is, but if your graphs spawn child processes you'll want tini — add it via dockerfile_lines in langgraph.json.

Multi-instance semantics

With --max-instances above 1 and Postgres + Redis configured, cross-instance cancellation and the one-run-per-thread guard both hold — no session affinity needed. See Scaling past one instance for what that costs in Postgres connections.