Deploy on Google Cloud Run
Kindgi's Terraform module runs the runtime and your pack's service as two Cloud Run services in one Google Cloud project, with everything around them. Built end to end on runtime 0.1.1, the first apply takes about ten minutes (most of it Cloud SQL), and a tool call from the runtime to your pack takes 42 ms at the median (100 ms at p95).
What you'll have
Section titled “What you'll have”- The runtime (
kindgi-server): Kindgi's server, on Cloud Run with its own service account. Public ingress, port 4000 (Cloud Run passes it asPORT, which the runtime listens on), always-allocated CPU (so a run started in the background keeps running after its answer), one instance. - Your pack's service (
kindgi-pack): your tools' code. Internal ingress, and IAM-protected: only the runtime's service account may call it, with a Google ID token on every call. - Cloud SQL (Postgres 16) for the runtime's data, reached through the Cloud SQL socket.
- Artifact Registry for both images. The runtime also reads your pack's image from it when you deploy.
- Secret Manager for the runtime's secrets, and a Cloud KMS key that wraps the secrets the runtime stores (model API keys, webhook secrets).
- A VPC with Direct VPC egress and Cloud NAT, which carries the runtime's calls to your pack's service and out to model APIs and webhooks.
If your organization forbids public access to Cloud Run (the
iam.allowedPolicyMemberDomains policy refuses allUsers), set
server_invoker_iam_disabled = true and server_public = false: the
runtime's API token still guards every call.
Who calls whom: your app calls the runtime (its API token); the runtime calls
your pack's service (an ID token), Cloud SQL, and model APIs; kindgi deploy
calls the runtime with a signed envelope, and the runtime reads the pack's
image from Artifact Registry.
Before you start
Section titled “Before you start”- A Google Cloud project, with an Owner (or Editor plus Security Admin and Secret Manager Admin) to apply the module, and a Cloud Storage bucket for Terraform's state.
- Terraform 1.6 or later, gcloud, Docker with
buildx, and your pack'skindgiCLI (0.1.2 or later, forkindgi key trust). - Runtime 0.1.1 or later. Cloud SQL has no superuser; 0.1.0 can't start on it.
- Access to the runtime image (
kindgi auth registry; see Install). - A license key: a non-production key covers staging; contact@kindgi.com.
- A pack that
kindgi build --local --pushbuilds, with an environment block for this deployment in its config.
1. The foundation
Section titled “1. The foundation”The services need images and secret values that don't exist yet, so the first apply creates everything else: the APIs, the network, the service accounts, the repository, the KMS key, Cloud SQL and the secrets' containers.
The module is deploy/gcp-cloud-run/ in the
kindgi-sdk repository:
copy the folder from the release you run. Its README.md lists every
variable. example.tfvars is the shape on this page (a new VPC);
example-connector.tfvars runs in a VPC you already have, through a
Serverless VPC Access connector.
cp example.tfvars prod.tfvars # project_id, region, kindgi_env, the seed ids, …terraform init -backend-config=bucket=<state bucket> -backend-config=prefix=<this deployment>terraform apply -var-file=prod.tfvars \ -target=google_project_service.apis \ -target=google_compute_router_nat.nat \ -target=google_artifact_registry_repository_iam_member.server_reads_images \ -target=google_kms_crypto_key_iam_member.server_wraps \ -target=google_sql_database.kindgi \ -target=google_secret_manager_secret_iam_member.server_reads \ -target=google_secret_manager_secret_iam_member.pack_reads_token \ -target=google_project_iam_member.server_sql_clientGive each deployment its own state prefix, never shared with your app's own
infrastructure.
The plan creates 34 resources. Cloud SQL is the long one, about six minutes.
The module sets Cloud SQL's edition to ENTERPRISE. A Postgres 16 instance
otherwise defaults to ENTERPRISE_PLUS, which takes only the
db-perf-optimized-N-* tiers, and the apply fails with
Invalid Tier (db-f1-micro) for (ENTERPRISE_PLUS) Edition.
2. The images into Artifact Registry
Section titled “2. The images into Artifact Registry”Cloud Run pulls from Artifact Registry, not from the runtime's private registry. Copy the runtime image there by digest, with every platform it was built for:
REPO=$(terraform output -raw image_repository)gcloud auth configure-docker "${REPO%%/*}"docker buildx imagetools create --tag "$REPO/runtime:0.1.3" \ quay.io/kindgi/runtime:0.1.3@sha256:<the release's digest>The copy keeps the release's digest. (A plain docker pull, tag and push
from an Apple silicon machine pushes only the arm64 image, which Cloud Run
can't run.)
Then build and push your pack's image, signed:
pnpm exec kindgi build --local --push --env prod✓ Pushed …/acme@sha256:bd7bfe4f…✓ /app/index.json in the image matches the local index byte for byte✓ Ed25519 signature over (imageDigest, artifactVersion, indexHash, tenantId, publishedAt)Set server_image and pack_image in prod.tfvars to the two digests
(…@sha256:…).
3. The secrets
Section titled “3. The secrets”Make each value here and pipe it straight into Secret Manager: it's never on disk or in Terraform's state, and Kindgi generates none of them.
N=kindgi # name_prefixCONN=$(terraform output -raw sql_connection_name)
# Kindgi's database user: a built-in user, not an IAM one. The instance is# named after name_prefix ($N); the database is database_name (kindgi).DBPW=$(openssl rand -hex 24)gcloud sql users create kindgi --instance=$N --password="$DBPW"printf 'postgres://kindgi:%s@/kindgi?host=/cloudsql/%s' "$DBPW" "$CONN" \ | gcloud secrets versions add $N-database-url --data-file=-unset DBPW
# The token the runtime and the pack's service share.openssl rand -hex 32 | tr -d '\n' | gcloud secrets versions add $N-pack-service-token --data-file=-
# The first API token, and the two keys, base64.printf 'kgi_bt_%s' "$(openssl rand -hex 32)" | gcloud secrets versions add $N-api-token --data-file=-openssl rand 32 | base64 | gcloud secrets versions add $N-secrets-aad-key --data-file=-openssl genpkey -algorithm ed25519 | base64 | gcloud secrets versions add $N-public-token-key --data-file=-
# The license key, pasted, never echoed.read -rs LICENSE_KEY && printf '%s' "$LICENSE_KEY" | gcloud secrets versions add $N-license-key --data-file=- && unset LICENSE_KEYThe database user is a built-in Cloud SQL user. It isn't a superuser, but
it has CREATEROLE and owns the database through cloudsqlsuperuser, which is
what the runtime needs. An IAM database user has neither.
Your pack's own secrets: kindgi env plan --env prod lists what your pack
needs. Create each secret, add its value, and the module passes them to the
pack's service:
pnpm exec kindgi env plan --env prod > /tmp/pack-env.jsonjq '{pack_env: .env, pack_secret_env: .secret_env}' /tmp/pack-env.json > prod.pack-env.auto.tfvars.json4. The services
Section titled “4. The services”terraform apply -var-file=prod.tfvarsThe pack's service comes up first (22 seconds), and is ready only when every module loaded and every required variable is set. Then the runtime. Its startup log names the pack's service it reached, and how it calls it:
Pack service: https://kindgi-pack-…a.run.app — acme (artifact 20261004.1), protocol 2, 3 tools, 1 checkPack service auth: a Google ID token per call (KINDGI_PACK_SERVICE_AUTH)How the runtime calls your pack's service
Section titled “How the runtime calls your pack's service”The pack's service has internal ingress and requires IAM, so nothing but the
runtime reaches it. KINDGI_PACK_SERVICE_AUTH=google-id-token makes the
runtime mint a Google ID token for the pack's URL, from its own service
account, on every call. Cloud Run checks it (the runtime's service account
has roles/run.invoker on the pack's service, and no one else does), then the
pack's service checks the shared pack token.
If the pack's service refuses the token, the runtime's startup log says so:
⚠ Pack service at https://… isn't answering (pack-service-unauthorized: The pack service rejected the pack token).
5. Trust your key, and deploy
Section titled “5. Trust your key, and deploy”pnpm exec kindgi key trust acme-prod --url "$(terraform output -raw server_url)" --token "$KINDGI_API_TOKEN" ✓ Trusted acme-prod (sha256:…)pnpm exec kindgi deploy --env prod --endpoint "$(terraform output -raw server_url)" --token "$KINDGI_API_TOKEN"✓ POST /v1/deployments → 201 Created artifactVersion: 20261004.1 primitives: 3 tools, 1 guardrail, 1 agent, 2 flowsDeploy complete.The runtime read your pack's image from Artifact Registry with its own token
(KINDGI_IMAGE_REGISTRY_AUTH=google) to check it before registering it.
A deploy that's refused (an untrusted key, a missing variable) says what to fix. Fix it and run the same command again.
6. Check it
Section titled “6. Check it”curl "$(terraform output -raw server_url)/health" # {"ok":true}pnpm exec kindgi tools list --url … --token … # your pack's toolspnpm exec kindgi runs start --flow=acme.greet-echo --input='{"name":"Ada"}' --url … --token …In the verification run: every run completed; a new instance of the runtime was ready in about 7 seconds (8 with its first migrations), the pack's service in about 5.
The IAM it sets up
Section titled “The IAM it sets up”| Who | Role | On |
|---|---|---|
| The runtime's service account | roles/run.invoker |
the pack's service (and no one else) |
roles/cloudkms.cryptoKeyEncrypterDecrypter |
the KMS key (the startup check encrypts and decrypts with it) | |
roles/cloudkms.viewer |
the KMS key; needed only by runtimes before 0.1.3, whose startup check read the key's metadata | |
roles/artifactregistry.reader |
the repository | |
roles/cloudsql.client |
the project, conditioned on Kindgi's instance | |
roles/secretmanager.secretAccessor |
each of its secrets | |
roles/aiplatform.user, only with vertex_ai = true |
the project: Gemini | |
| The pack's service account | roles/secretmanager.secretAccessor |
the pack token and your pack's secrets |
| what your tools need | your own resources |
Use Gemini
Section titled “Use Gemini”The runtime can call Gemini on Vertex AI with its own service account, so
there's no key to store. Set vertex_ai = true and apply: the module turns
on the Vertex AI API and grants the runtime's service account
roles/aiplatform.user. Then register the preset:
pnpm exec kindgi providers register --preset=gemini --project=<project> --models=gemini-2.5-flash --url … --token …✓ Registered gemini: gemini-2.5-flashWithout the role, every model call fails:
Model call to gemini (gemini-2.5-flash) failed: {"error":{"code":403,"message":"Permission 'aiplatform.endpoints.predict' denied on resource '//aiplatform.googleapis.com/projects/<project>/locations/global/publishers/google/models/gemini-2.5-flash' (or it may not exist). …A new grant can take a minute or two to apply.
Operate it
Section titled “Operate it”- Upgrade: back up Cloud SQL, copy the new runtime image by digest, set
server_image, and apply. The new revision takes all the traffic. Migrations only go forward: never run two runtime versions on one database, and go back by restoring the backup. - Rotate a secret: add a version, then roll a new revision of each service
that reads it (
gcloud run services update … --update-labels=rotated=$(date +%s)). Never rotate the AAD key this way: every stored secret is bound to it. - Logs: Cloud Logging, per service. The runtime's startup lines are in Operate.
Tear it down
Section titled “Tear it down”Cloud SQL is protected from deletion: set database_deletion_protection = false and apply first. The KMS key ring and key outlive terraform destroy:
Google Cloud never deletes them. Take them out of Terraform's state, destroy
the rest, then schedule the key's versions for destruction:
terraform apply -var-file=prod.tfvars -var=database_deletion_protection=falseterraform state rm google_kms_crypto_key.secrets google_kms_key_ring.kindgiterraform destroy -var-file=prod.tfvarsgcloud kms keys versions destroy 1 --key=… --keyring=… --location=…If the destroy stops at the subnet (is already being used by …/addresses/serverless-ipv4-cloudrun-…),
Cloud Run hasn't released its addresses yet: run it again later.
Limits today
Section titled “Limits today”- One runtime instance. Several aren't supported yet.
- Signing in through OAuth doesn't work on Cloud SQL yet; API tokens do.