Skip to content

Deploy on GCP (Cloud Run + GKE)

This guide walks through deploying a production-ready Scion HA Hub on Google Cloud Platform. The architecture uses Cloud Run for the Hub (with IAP authentication), GKE Autopilot for agent dispatch, and Cloud SQL for durable state.

What you will set up:

Component GCP Service Purpose
Hub Cloud Run (HA, multi-instance) Control plane, API, web UI
Database Cloud SQL PostgreSQL Durable state (Ent AutoMigrate)
Agent Runtime GKE Autopilot Isolated agent pods
Auth Identity-Aware Proxy (IAP) Zero-trust access control
Secrets Secret Manager + CSI Driver Secure secret distribution
Storage GCS Templates, artifacts, hub data
Images Artifact Registry Container image repository

You need an active GCP project with billing enabled. Throughout this guide, replace $PROJECT_ID with your project ID and $PROJECT_NUMBER with your project number.

Terminal window
export PROJECT_ID="your-project-id"
export PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format="value(projectNumber)")
export REGION="us-central1" # Hub + Cloud SQL region
export GKE_REGION="us-west2" # GKE cluster region (can differ from Hub)
Terminal window
gcloud services enable \
run.googleapis.com \
sqladmin.googleapis.com \
secretmanager.googleapis.com \
container.googleapis.com \
cloudbuild.googleapis.com \
artifactregistry.googleapis.com \
iap.googleapis.com \
iam.googleapis.com \
--project=$PROJECT_ID
Tool Install Verify
gcloud cloud.google.com/sdk/install gcloud version
kubectl gcloud components install kubectl kubectl version --client
gke-gcloud-auth-plugin See below gke-gcloud-auth-plugin --version
psql sudo apt-get install -y postgresql-client psql --version
scion CLI Per Scion repo README scion version
Terminal window
gcloud auth login
gcloud config set project $PROJECT_ID
gcloud auth application-default login

For Automated Deployers / Service Accounts (Non-Interactive / Cross-Project)

Section titled “For Automated Deployers / Service Accounts (Non-Interactive / Cross-Project)”

If an automated CI/CD agent or a service account from another project is executing this deployment:

  1. Authenticate using the service account’s key file:
    Terminal window
    gcloud auth activate-service-account --key-file=KEY_FILE_PATH
    gcloud config set project $PROJECT_ID
  2. Note that interactive authentication commands (gcloud auth login and gcloud auth application-default login) are disabled and should be skipped.

The deployment identity (whether human or service account) must bypass two separate permission walls: project-level IAM bindings and service-account-level IAM bindings. Ensure the deployer has the following consolidated roles on the target project:

Role Scope Why It’s Needed
roles/resourcemanager.projectIamAdmin Project-level Needed to assign project-level IAM policies to service accounts (Section 2)
roles/iam.serviceAccountAdmin Service-account-level Needed to set service-account-level policy bindings (such as Token Creator in 2c, 2d and Workload Identity in 2g)
roles/run.admin Service-level Needed to deploy Cloud Run services and bind service-level permissions (run.invoker)
roles/editor Project-level General resource provisioning (Cloud SQL, GCS, Secret Manager, GKE)

If deploying from a service account in a different “Infrastructure” project (e.g., scion-deployer@infra-project.iam.gserviceaccount.com), grant the required roles to that service account on the target project before proceeding:

Terminal window
DEPLOYER_SA="scion-deployer@infra-project.iam.gserviceaccount.com"
for ROLE in \
roles/resourcemanager.projectIamAdmin \
roles/iam.serviceAccountAdmin \
roles/run.admin \
roles/editor; do
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:$DEPLOYER_SA" \
--role="$ROLE" \
--quiet
done

Terminal window
gcloud artifacts repositories create scion \
--repository-format=docker \
--location=$REGION \
--project=$PROJECT_ID

Set the registry prefix (used throughout):

Terminal window
export IMAGE_REGISTRY="$REGION-docker.pkg.dev/$PROJECT_ID/scion"
Terminal window
gcloud sql instances create scion-hub-db \
--database-version=POSTGRES_16 \
--edition=ENTERPRISE \
--tier=db-f1-micro \
--region=$REGION \
--project=$PROJECT_ID \
--database-flags=max_connections=200

Create the database user and database:

Terminal window
# Set password (choose your own secure password)
export DB_PASSWORD="YourSecurePassword"
gcloud sql users create scion \
--instance=scion-hub-db \
--password=$DB_PASSWORD \
--project=$PROJECT_ID
# The database is created automatically by Ent AutoMigrate on first Hub boot.
gcloud sql databases create scionhub \
--instance=scion-hub-db \
--project=$PROJECT_ID
Terminal window
export BUCKET_NAME="scion-hub-$PROJECT_ID"
gsutil mb -l $REGION -p $PROJECT_ID gs://$BUCKET_NAME/
Terminal window
gcloud container clusters create-auto scion-agents \
--region=$GKE_REGION \
--project=$PROJECT_ID \
--enable-secret-manager

Create the agent namespace:

Terminal window
# Get credentials for kubectl
gcloud container clusters get-credentials scion-agents \
--region=$GKE_REGION --project=$PROJECT_ID
kubectl create namespace scion-agents

Verify:

Terminal window
kubectl get namespace scion-agents
# Expected: scion-agents Active
# Verify CSI driver is running
kubectl get pods -n kube-system | grep csi-secrets-store
# Expected: csi-secrets-store-gke-* pods Running

The Hub needs a kubeconfig to dispatch agents to GKE. Store it in Secret Manager:

Terminal window
# Generate a kubeconfig for the scion-agents cluster
KUBECONFIG=/tmp/gke-kubeconfig.yaml gcloud container clusters get-credentials \
scion-agents --region=$GKE_REGION --project=$PROJECT_ID
# Store in Secret Manager
gcloud secrets create scion-gke-kubeconfig \
--data-file=/tmp/gke-kubeconfig.yaml \
--project=$PROJECT_ID
# Clean up local file
rm /tmp/gke-kubeconfig.yaml

The Hub uses a 32-byte hex session signing key:

Terminal window
export SCION_SERVER_SESSION_SECRET=$(openssl rand -hex 32)
echo "Session secret: $SCION_SERVER_SESSION_SECRET"
# Save this — you'll use it in the deploy command.
Terminal window
echo "=== Infrastructure Verification ==="
echo "Artifact Registry:"
gcloud artifacts repositories list --location=$REGION --project=$PROJECT_ID \
--format="value(name)" | grep scion && echo " OK" || echo " MISSING"
echo "Cloud SQL:"
gcloud sql instances describe scion-hub-db --project=$PROJECT_ID \
--format="value(state)" 2>/dev/null && echo " OK" || echo " MISSING"
echo "GCS Bucket:"
gsutil ls -b gs://$BUCKET_NAME/ >/dev/null 2>&1 && echo " OK" || echo " MISSING"
echo "GKE Cluster:"
gcloud container clusters describe scion-agents --region=$GKE_REGION \
--project=$PROJECT_ID --format="value(status)" 2>/dev/null && echo " OK" || echo " MISSING"
echo "Kubeconfig Secret:"
gcloud secrets describe scion-gke-kubeconfig --project=$PROJECT_ID \
--format="value(name)" 2>/dev/null && echo " OK" || echo " MISSING"

Terminal window
# Hub runtime SA — runs the Cloud Run Hub service
gcloud iam service-accounts create scion-hub-runner \
--display-name="Scion Hub Runner" \
--project=$PROJECT_ID
# Transport SA — identity used to mint IAP tokens for GKE agents
gcloud iam service-accounts create scion-transport \
--display-name="Scion Transport Token Minter" \
--project=$PROJECT_ID
# Discord runtime SA — runs the Discord Cloud Run service
gcloud iam service-accounts create scion-discord-runner \
--display-name="Scion Discord Runner" \
--project=$PROJECT_ID
Terminal window
SA_HUB="scion-hub-runner@$PROJECT_ID.iam.gserviceaccount.com"
for ROLE in \
roles/cloudsql.client \
roles/storage.objectAdmin \
roles/secretmanager.admin \
roles/container.developer; do
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:$SA_HUB" \
--role="$ROLE" \
--quiet
done
Role Purpose
roles/cloudsql.client Connect to Cloud SQL via Unix socket
roles/storage.objectAdmin Read/write GCS bucket (templates, artifacts)
roles/secretmanager.admin Required. Read, write, and create Secret Manager secrets (e.g. API keys saved by users via the Hub web UI). Traditional viewer/accessor roles are too restrictive and cause “Internal Error” failures when writing secrets.
roles/container.developer Dispatch agent pods to GKE, create K8s resources

2c. Hub Runner SA — Token Creator on Transport SA

Section titled “2c. Hub Runner SA — Token Creator on Transport SA”
Terminal window
SA_TRANSPORT="scion-transport@$PROJECT_ID.iam.gserviceaccount.com"
gcloud iam service-accounts add-iam-policy-binding \
$SA_TRANSPORT \
--member="serviceAccount:$SA_HUB" \
--role="roles/iam.serviceAccountTokenCreator" \
--project=$PROJECT_ID

Why this exists: When dispatching a GKE agent, the Hub mints an OIDC identity token on behalf of the transport SA (via gcpTransportMinter.MintIDToken). This token is injected as SCION_TRANSPORT_TOKEN into the agent pod. The agent uses this token as Authorization: Bearer for all Hub API calls through IAP. The serviceAccountTokenCreator role authorizes the Hub SA to mint tokens as the transport SA.

2d. Hub Runner SA — Token Creator on Itself

Section titled “2d. Hub Runner SA — Token Creator on Itself”

The Hub generates signed GCS URLs for template files when dispatching agents. This operation requires the iam.serviceAccounts.signBlob permission, which requires the Hub Runner SA to have the Token Creator role on itself:

Terminal window
gcloud iam service-accounts add-iam-policy-binding \
$SA_HUB \
--member="serviceAccount:$SA_HUB" \
--role="roles/iam.serviceAccountTokenCreator" \
--project=$PROJECT_ID

Without this binding, template generation will fail with: Permission 'iam.serviceAccounts.signBlob' denied on resource.

2e. Hub Runner SA — IAM Security Reviewer (Optional, for Policy Troubleshooter)

Section titled “2e. Hub Runner SA — IAM Security Reviewer (Optional, for Policy Troubleshooter)”

When you enable GCP IAM check mode (gcp_iam_check_mode: enforce), Scion verifies that human and agent callers possess iam.serviceAccounts.actAs permission on assigned service accounts via the GCP Policy Troubleshooter v3 API.

For Policy Troubleshooter to evaluate permissions across your project (or your entire organization if you assign SAs from multiple projects), the Hub Runner SA must be granted the IAM Security Reviewer role (roles/iam.securityReviewer):

Terminal window
# Grant Security Reviewer at the project level
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:$SA_HUB" \
--role="roles/iam.securityReviewer" \
--quiet
# (Optional) For multi-project or organization-wide SAs, grant at the org level instead:
# export ORG_ID="your-gcp-organization-id"
# gcloud organizations add-iam-policy-binding $ORG_ID \
# --member="serviceAccount:$SA_HUB" \
# --role="roles/iam.securityReviewer"

Without this permission, Policy Troubleshooter checks will return an indeterminate or unknown status (such as ACCESS_STATE_UNKNOWN_INFO_DENIED), which results in a fail-closed denial of service account assignment in Scion.

The Hub’s identity needs access to run the assignment check for each target service account; until it has that access, service account assignment is denied. If you assign service accounts from other GCP projects and have not granted the role at the organization level, grant it on each of those projects too (replace $SA_PROJECT_ID with the project that holds the service account):

Terminal window
gcloud projects add-iam-policy-binding $SA_PROJECT_ID \
--member="serviceAccount:$SA_HUB" \
--role="roles/iam.securityReviewer"

Without it, assigning a service account from that project is refused with a message that names the project and gives this command. Registering or verifying such an account returns the same message as a warning, so you can grant the role before the first assignment. If the check cannot run because of the Hub’s identity, the admin health summary reports it (see Hub Identity Access for the Assignment Check).

The transport SA’s identity is used in the IAP token. IAP must allow this identity to access the Hub:

Terminal window
# Grant IAP access at project level
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:$SA_TRANSPORT" \
--role="roles/iap.httpsResourceAccessor" \
--quiet

Also grant Cloud Run invoker (needed for the underlying service):

Terminal window
# Run this command AFTER the scion-hub service is created in Section 3
gcloud run services add-iam-policy-binding scion-hub \
--member="serviceAccount:$SA_TRANSPORT" \
--role="roles/run.invoker" \
--project=$PROJECT_ID \
--region=$REGION

The Discord service calls Hub APIs through IAP (for registration, message routing, etc.):

Terminal window
SA_DISCORD="scion-discord-runner@$PROJECT_ID.iam.gserviceaccount.com"
# Cloud SQL access (for Discord's database interactions)
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:$SA_DISCORD" \
--role="roles/cloudsql.client" \
--quiet
# IAP access to call Hub APIs
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:$SA_DISCORD" \
--role="roles/iap.httpsResourceAccessor" \
--quiet

2h. GKE Workload Identity — Agent Pod Secret Access

Section titled “2h. GKE Workload Identity — Agent Pod Secret Access”

Agent pods need Workload Identity (WI) bindings to access Secret Manager for CSI-mounted secrets:

Terminal window
# Use the default compute SA or create a dedicated one
export GKE_GSA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
# Annotate the KSA in scion-agents namespace
kubectl annotate serviceaccount default \
--namespace=scion-agents \
iam.gke.io/gcp-service-account=$GKE_GSA \
--overwrite
# Grant the WI binding (KSA → GSA)
gcloud iam service-accounts add-iam-policy-binding \
$GKE_GSA \
--role=roles/iam.workloadIdentityUser \
--member="serviceAccount:$PROJECT_ID.svc.id.goog[scion-agents/default]" \
--project=$PROJECT_ID
# Ensure the GSA can access secrets
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:$GKE_GSA" \
--role="roles/secretmanager.secretAccessor" \
--quiet

2i. GKE Workload Identity for GCP Identity Mode Assign

Section titled “2i. GKE Workload Identity for GCP Identity Mode Assign”

GCP identity mode assign on the Kubernetes runtime authenticates agent pods via Workload Identity, not via the sciontool metadata emulator used on Docker. Scion does not create, annotate, or bind Kubernetes ServiceAccounts (KSAs) for this — pre-provision each one yourself, then tell the broker about it in its own settings.yaml.

This section covers the cluster side: the Workload Identity binding and the broker mapping. Registering the GSA with the Hub and granting the Hub token-creator on it come first; see the setup checklist for the full order.

Cluster and node pool prerequisites (once per cluster, if not already done for 2h):

Terminal window
# Enable Workload Identity Federation on the cluster.
gcloud container clusters update CLUSTER_NAME \
--location=REGION \
--workload-pool=$PROJECT_ID.svc.id.goog
# Enable GKE_METADATA on every node pool agent pods run on.
gcloud container node-pools update NODE_POOL_NAME \
--cluster=CLUSTER_NAME \
--location=REGION \
--workload-metadata=GKE_METADATA

For every GCP service account (GSA) you plan to assign to agents on this cluster:

Terminal window
# Choose (or create) a KSA for this GSA, in the namespace agents dispatch to.
export AGENT_GSA="agent-worker@$PROJECT_ID.iam.gserviceaccount.com"
export AGENT_KSA="agent-worker-ksa"
export AGENT_NAMESPACE="scion-agents"
kubectl create serviceaccount $AGENT_KSA --namespace=$AGENT_NAMESPACE \
--dry-run=client -o yaml | kubectl apply -f -
# Annotate the KSA with the GSA it is bound to.
kubectl annotate serviceaccount $AGENT_KSA \
--namespace=$AGENT_NAMESPACE \
iam.gke.io/gcp-service-account=$AGENT_GSA \
--overwrite
# Grant the WI binding (KSA -> GSA), scoped to this namespace and KSA.
gcloud iam service-accounts add-iam-policy-binding \
$AGENT_GSA \
--role=roles/iam.workloadIdentityUser \
--member="serviceAccount:$PROJECT_ID.svc.id.goog[$AGENT_NAMESPACE/$AGENT_KSA]" \
--project=$PROJECT_ID

Then add the GSA-to-KSA mapping to the runtime broker’s own global settings — never a project’s own settings.yaml, which this mapping deliberately ignores — under the kubernetes-typed runtime entry, or under a profile in that same global source to override the runtime-level mapping for agents created under that profile only:

runtimes:
remote:
type: kubernetes
kubernetes_service_account_mappings:
agent-worker@PROJECT_ID.iam.gserviceaccount.com: agent-worker-ksa

Where “global settings” lives depends on your deployment mode:

  • File-only mode (no database configured): this is ~/.scion/settings.yaml on the broker host, as shown above.
  • Broker in the same process as a database-backed Hub (the Cloud Run + Cloud SQL setup in this guide, which starts the Hub and the Runtime Broker together): as §3c below explains, runtimes and profiles are persisted to the database on first boot and the database then takes over as the source of truth for those sections — editing the settings.yaml secret afterward and redeploying has no effect on them, the same way it has no effect on admin_emails. Set or update the mapping with PUT /api/v1/admin/server-config (the Hub’s Settings admin page sends this same request) instead, sending the complete runtimes and/or profiles object you want in effect — a mapping added only to the settings.yaml secret after first boot is silently ignored, and every assign dispatch then fails with “no mapping” even though the file looks correct.
  • Standalone Runtime Broker (running in a separate process from the Hub, even when the Hub uses a database): the broker reads only its own ~/.scion/settings.yaml; the Hub database’s runtimes and profiles do not apply to it.

A Kubernetes dispatch with GCP identity mode assign whose GSA has no entry here fails at dispatch time with an actionable error naming this setting — it does not fall back to the emulator or to the pod’s default identity. This mapping is authoritative: if the create or start request itself also names an explicit kubernetes.serviceAccountName that differs from the mapped KSA for that GSA, the dispatch is rejected rather than silently resolved either way. A serviceAccountName set only in a template (not on the request) is not a conflict — it is overridden by the mapping instead, the same way any other request-level value already overrides a template value.

The namespace an assign dispatch runs in comes only from the broker’s global settings: the selected runtime entry’s namespace, then the runtime’s default namespace. Provision the mapped KSA in that namespace:

runtimes:
remote:
type: kubernetes
namespace: scion-agents
kubernetes_service_account_mappings:
agent-worker@PROJECT_ID.iam.gserviceaccount.com: agent-worker-ksa

Profiles have no namespace setting of their own. A profile that needs a different namespace selects its own runtime entry, which sets that namespace and the mapping for the KSA provisioned there:

runtimes:
remote:
type: kubernetes
namespace: scion-agents
kubernetes_service_account_mappings:
agent-worker@PROJECT_ID.iam.gserviceaccount.com: agent-worker-ksa
remote-team:
type: kubernetes
namespace: team-agents
kubernetes_service_account_mappings:
agent-worker@PROJECT_ID.iam.gserviceaccount.com: team-worker-ksa
profiles:
team:
runtime: remote-team

A create or start request that names a namespace explicitly is accepted only when it equals that resolved namespace.

A project’s own settings.yaml can still set runtimes.<entry>.namespace or runtimes.<entry>.context for the entry an assign dispatch selects, and the pod would be placed with the project’s value. Such a dispatch is refused with 400, naming the runtime entry and both values; remove the project’s override, or select another runtime entry.

A broker whose server configuration forces a runtime (ForceRuntime, which skips profile resolution) does not consult profiles for this lookup either. It reads the mapping and the namespace from the runtime entry whose key is the forced runtime’s type name, for example runtimes: kubernetes:. The forced Kubernetes runtime places pods in its own namespace, so that namespace must equal the entry’s resolved namespace; otherwise the dispatch is refused with 400. The entry’s context is not consulted in this case.

Terminal window
echo "=== Hub Runner SA Roles ==="
gcloud projects get-iam-policy $PROJECT_ID \
--flatten="bindings[].members" \
--filter="bindings.members:$SA_HUB" \
--format="table(bindings.role)"
echo ""
echo "=== Token Creator Binding on Transport SA ==="
gcloud iam service-accounts get-iam-policy $SA_TRANSPORT \
--project=$PROJECT_ID \
--format="yaml(bindings)"
echo ""
echo "=== Transport SA IAP Access ==="
gcloud projects get-iam-policy $PROJECT_ID \
--flatten="bindings[].members" \
--filter="bindings.members:$SA_TRANSPORT" \
--format="table(bindings.role)"
Service Account Role Scope Purpose
scion-hub-runner roles/cloudsql.client Project Cloud SQL connections
scion-hub-runner roles/storage.objectAdmin Project GCS bucket read/write
scion-hub-runner roles/secretmanager.admin Project Read, write, create secret values
scion-hub-runner roles/container.developer Project GKE agent dispatch
scion-hub-runner roles/iam.serviceAccountTokenCreator On itself Generate GCS signed URLs
scion-hub-runner roles/iam.serviceAccountTokenCreator On scion-transport SA Mint IAP tokens for agents
scion-hub-runner roles/iam.securityReviewer Project/Org, plus each project that holds an assigned SA Policy Troubleshooter (required for SA assignment when gcp_iam_check_mode: enforce)
scion-transport roles/iap.httpsResourceAccessor Project Allow IAP access to Hub
scion-transport roles/run.invoker Hub service Allow Cloud Run invocation
scion-discord-runner roles/cloudsql.client Project Cloud SQL connections
scion-discord-runner roles/iap.httpsResourceAccessor Project Call Hub API through IAP
GKE pods GSA roles/secretmanager.secretAccessor Project CSI secret mounts
GKE pods GSA roles/iam.workloadIdentityUser On itself, for KSA WI auth for pods

For programmatic access to an IAP-protected Cloud Run service (which our GKE agent pods require), you must set up a custom OAuth Client ID:

  1. In the GCP Console, go to APIs & Services > Credentials.
  2. Click Create Credentials and select OAuth client ID.
  3. Set the Application type to Web application.
  4. Set the Name to scion-hub-iap.
  5. Under Authorized redirect URIs, add the following native IAP redirect path: https://iap.googleapis.com/v1/oauth/clientIds/YOUR_CLIENT_ID:handleRedirect (Note: replace YOUR_CLIENT_ID with the actual client ID string generated after creation).
  6. Export the Client ID for subsequent steps:
    Terminal window
    export IAP_CLIENT_ID="your-client-id-string.apps.googleusercontent.com"

Scion agent image building follows a strict dependency chain: $$\text{core-base} \longrightarrow \text{scion-base} \longrightarrow \text{harnesses (gemini-cli, etc.)}$$

  1. core-base: Built on node:24-trixie-slim (Debian 13). Contains core tools (Go compiler, Git vendored from the Chainguard git image, unix packages, GCS FUSE).
  2. scion-base: Copies repository code (cmd/, pkg/, etc.) and builds the scion and sciontool binaries on top of core-base.
  3. Harnesses: Pulls scion-base and adds target agent packages (like @google/gemini-cli).
Terminal window
git clone https://github.com/GoogleCloudPlatform/scion.git
cd scion
Terminal window
gcloud builds submit . \
--tag="$IMAGE_REGISTRY/hub:latest" \
--project=$PROJECT_ID \
--ignore-file=.dockerignore \
--machine-type=e2-highcpu-8 \
--quiet
Terminal window
gcloud builds submit image-build \
--tag="$IMAGE_REGISTRY/core-base:latest" \
--dockerfile=core-base/Dockerfile \
--project=$PROJECT_ID \
--quiet

Create a single-arch temporary build file to inject the custom BASE_IMAGE cleanly:

Terminal window
cat <<EOF > /tmp/scion-base-build.yaml
steps:
- name: 'gcr.io/cloud-builders/docker'
args: [
'build',
'--build-arg', 'BASE_IMAGE=$IMAGE_REGISTRY/core-base:latest',
'-t', '$IMAGE_REGISTRY/scion-base:latest',
'-f', 'image-build/scion-base/Dockerfile',
'.'
]
images:
- '$IMAGE_REGISTRY/scion-base:latest'
EOF
gcloud builds submit . --config /tmp/scion-base-build.yaml --project=$PROJECT_ID --quiet
rm /tmp/scion-base-build.yaml

Create a single-arch temporary build file:

Terminal window
cat <<EOF > /tmp/gemini-cli-build.yaml
steps:
- name: 'gcr.io/cloud-builders/docker'
args: [
'build',
'--build-arg', 'BASE_IMAGE=$IMAGE_REGISTRY/scion-base:latest',
'-t', '$IMAGE_REGISTRY/scion-gemini-cli:latest',
'-f', 'harnesses/gemini-cli/Dockerfile',
'.'
]
images:
- '$IMAGE_REGISTRY/scion-gemini-cli:latest'
EOF
gcloud builds submit . --config /tmp/gemini-cli-build.yaml --project=$PROJECT_ID --quiet
rm /tmp/gemini-cli-build.yaml

To eliminate extremely fragile shell escaping and prevent nested quotation failures, do not embed settings.yaml within startup commands. Instead, write a clean local settings.yaml, save it as a Secret Manager secret, and mount it natively as a file on Cloud Run.

Create a local settings.yaml file:

schema_version: "1" # REQUIRED — must be first key
image_registry: REGISTRY_PLACEHOLDER # Overwritten with $IMAGE_REGISTRY
active_profile: remote # Default dispatch runtime is GKE
# === RUNTIME DEFINITIONS ===
runtimes:
remote:
type: kubernetes
gke: true # Enables CSI Secret Manager mounts
# === PROFILE MAPPINGS ===
profiles:
remote:
runtime: remote
# === SERVER CONFIGURATION ===
server:
database:
driver: postgres
url: postgres://scion:DB_PASSWORD_PLACEHOLDER@/scionhub?host=/cloudsql/PROJECT_ID:REGION:scion-hub-db
auth:
mode: proxy
proxy:
provider: iap
iap:
audience: /projects/PROJECT_NUMBER/locations/REGION/services/scion-hub
transport:
mode: iap
oidc_audience: IAP_CLIENT_ID_PLACEHOLDER
platform_auth_sa: scion-transport@PROJECT_ID.iam.gserviceaccount.com
# === OIDC IDENTITY PROVIDER ===
# Enables the Hub to act as an OIDC provider, publishing JWKS endpoints
# and minting identity tokens for agents to authenticate to external resources.
oidc:
enabled: true # Enabled to support agent identity tokens
token_lifetime: 15m # Lifetime of minted OIDC identity tokens
# === OIDC FEDERATION ===
# Enables inbound federation from trusted external OIDC providers.
federation:
enabled: false # Disabled by default, configure if needed
trusted_issuers: []
secrets:
backend: gcpsm
gcp_project_id: PROJECT_ID
hub:
hub_id: scion-hub-ha-prod # Stable identity — see note below
admin_emails:
- your-admin@example.com # Admin email addresses
hub_name: scion-hub-ha
storage:
provider: gcs
bucket: BUCKET_NAME_PLACEHOLDER
broker:
enabled: true
host: 127.0.0.1

Replace placeholders with your live values:

Terminal window
sed -e "s|REGISTRY_PLACEHOLDER|$IMAGE_REGISTRY|" \
-e "s|DB_PASSWORD_PLACEHOLDER|$DB_PASSWORD|" \
-e "s|PROJECT_ID|$PROJECT_ID|g" \
-e "s|PROJECT_NUMBER|$PROJECT_NUMBER|g" \
-e "s|REGION|$REGION|g" \
-e "s|IAP_CLIENT_ID_PLACEHOLDER|$IAP_CLIENT_ID|" \
-e "s|BUCKET_NAME_PLACEHOLDER|$BUCKET_NAME|" \
settings.yaml > /tmp/settings-final.yaml
Terminal window
gcloud secrets create scion-hub-settings \
--data-file=/tmp/settings-final.yaml \
--project=$PROJECT_ID
rm /tmp/settings-final.yaml

We can now deploy the Hub to Cloud Run cleanly. Notice that we mount both the kubeconfig and the settings.yaml files as secrets directly under /etc/scion/ and /home/scion/.scion/, keeping the start command extremely simple and robust.

Terminal window
gcloud run deploy scion-hub \
--image="$IMAGE_REGISTRY/hub:latest" \
--region=$REGION \
--project=$PROJECT_ID \
--service-account=scion-hub-runner@$PROJECT_ID.iam.gserviceaccount.com \
--add-cloudsql-instances=$PROJECT_ID:$REGION:scion-hub-db \
--set-env-vars="SCION_DEPLOY=$(date +%s),KUBECONFIG=/etc/scion/kubeconfig.yaml,SCION_K8S_NAMESPACE=scion-agents,SCION_SERVER_SESSION_SECRET=$SCION_SERVER_SESSION_SECRET,SCION_CLOUDRUN_CPU_ALWAYS_ALLOCATED=true" \
--set-secrets="/etc/scion/kubeconfig.yaml=scion-gke-kubeconfig:latest,/home/scion/.scion/settings.yaml=scion-hub-settings:latest" \
--min-instances=2 \
--max-instances=3 \
--cpu=1 \
--no-cpu-throttling \
--memory=512Mi \
--port=8080 \
--timeout=900 \
--command="/usr/local/bin/scion" \
--args="server,start,--hosted,--enable-hub,--enable-runtime-broker,--enable-web,--foreground,--web-port,8080" \
--no-allow-unauthenticated \
--quiet

--no-cpu-throttling keeps CPU allocated outside requests. The Runtime Broker in this service needs it to finish asynchronous agent launches after the create response. SCION_CLOUDRUN_CPU_ALWAYS_ALLOCATED=true tells the server that CPU is always allocated, which silences its startup warning; remove the variable if you deploy with CPU throttling. See Asynchronous agent create.


Once the Hub service is deployed, activate Native IAP via the CLI:

Terminal window
gcloud run services update scion-hub \
--iap=enabled \
--region=$REGION \
--project=$PROJECT_ID

Grant invoker permissions to both the scion-transport service account and the native IAP service agent so the authentication pipeline completes smoothly:

Terminal window
# Allow IAP service agent to invoke Cloud Run
gcloud run services add-iam-policy-binding scion-hub \
--region=$REGION \
--project=$PROJECT_ID \
--member="serviceAccount:service-$PROJECT_NUMBER@gcp-sa-iap.iam.gserviceaccount.com" \
--role="roles/run.invoker" \
--quiet
# Allow scion-transport SA to invoke Cloud Run
gcloud run services add-iam-policy-binding scion-hub \
--region=$REGION \
--project=$PROJECT_ID \
--member="serviceAccount:scion-transport@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/run.invoker" \
--quiet

Retrieve your live Hub URL:

Terminal window
export HUB_URL=$(gcloud run services describe scion-hub \
--region=$REGION --project=$PROJECT_ID \
--format="value(status.url)")
echo "Hub URL: $HUB_URL"

Verify that unauthenticated endpoints are successfully blocked by IAP:

Terminal window
curl -s -o /dev/null -w "%{http_code}" "$HUB_URL/"
# Expected: 302 (redirect to Google sign-in) or 403 (IAP block)
Terminal window
TOKEN=$(gcloud auth print-identity-token --audiences="$IAP_CLIENT_ID")
curl -s "$HUB_URL/health" \
-H "Authorization: Bearer $TOKEN"
# Expected: {"status":"healthy",...}

The Scion Discord service links Discord servers directly to the Hub API, enabling Slack/Discord-styled interactive chat dispatches.

Before deploying, you must register a bot application with Discord:

  1. Navigate to the Discord Developer Portal.
  2. Click New Application and choose a name (e.g. scion-bot).
  3. Under the Bot tab:
    • Click Reset Token and copy the generated Bot Token (save this as DISCORD_BOT_TOKEN).
    • Under Privileged Gateway Intents, enable Presence Intent, Server Members Intent, and Message Content Intent (required to read client messages).
  4. Under the OAuth2 tab, note the Client ID (save this as DISCORD_APPLICATION_ID).
  5. Open your Discord client, enable developer mode (User Settings > Advanced > Developer Mode), right-click your Discord Server, and copy its ID (save this as DISCORD_GUILD_IDS).
  6. Generate the Bot Invite Link:
    • Go to OAuth2 > URL Generator.
    • Under Scopes, select bot.
    • Under Bot Permissions, select Send Messages, Read Message History, and View Channel.
    • Copy the generated URL at the bottom and load it in your browser to invite the bot to your target server.

Ensure the build context is set to the repo root (.) and not extras/scion-discord/. The Discord service depends on parent modules within the workspace directory, so building from the subdirectory will throw compilation errors.

Terminal window
gcloud builds submit . \
--tag="$IMAGE_REGISTRY/discord:latest" \
--dockerfile=extras/scion-discord/Dockerfile \
--project=$PROJECT_ID \
--quiet

Variable Description
DATABASE_URL Cloud SQL PostgreSQL connection string
DISCORD_STANDALONE Instructs the plugin to run in standalone listener mode (true)
DISCORD_BOT_TOKEN Your secure Discord bot token
DISCORD_APPLICATION_ID Your Discord client/application ID
DISCORD_GUILD_IDS Comma-separated target Discord Guild (server) IDs
DISCORD_HUB_URL The fully qualified public URL of your Hub ($HUB_URL)
DISCORD_TRANSPORT_MODE Set to iap for Identity-Aware Proxy auth
DISCORD_TRANSPORT_AUDIENCE The OAuth client ID of the Hub ($IAP_CLIENT_ID)
Terminal window
gcloud run deploy scion-discord \
--image="$IMAGE_REGISTRY/discord:latest" \
--region=$REGION \
--project=$PROJECT_ID \
--service-account=scion-discord-runner@$PROJECT_ID.iam.gserviceaccount.com \
--add-cloudsql-instances=$PROJECT_ID:$REGION:scion-hub-db \
--use-http2 \
--port=8080 \
--set-env-vars="GRPC_PORT=8080,DISCORD_STANDALONE=true,DATABASE_URL=postgres://scion:${DB_PASSWORD}@/scionhub?host=/cloudsql/$PROJECT_ID:$REGION:scion-hub-db,DISCORD_BOT_TOKEN=$DISCORD_BOT_TOKEN,DISCORD_APPLICATION_ID=$DISCORD_APPLICATION_ID,DISCORD_GUILD_IDS=$DISCORD_GUILD_IDS,DISCORD_HUB_URL=$HUB_URL,DISCORD_TRANSPORT_MODE=iap,DISCORD_TRANSPORT_AUDIENCE=$IAP_CLIENT_ID" \
--min-instances=1 \
--max-instances=1 \
--cpu=1 \
--memory=512Mi \
--no-allow-unauthenticated \
--quiet

By this point you should have:

  • GKE Autopilot cluster with Secret Manager CSI add-on (Section 1d)
  • scion-agents namespace created (Section 1d)
  • Kubeconfig in Secret Manager (Section 1e)
  • Workload Identity binding (Section 2g)
  • Hub deployed with GKE support (Section 3d)
Terminal window
# Verify CSI driver is running
kubectl get csidrivers | grep secrets-store
# Expected: secrets-store-gke.csi.k8s.io
# Verify WI annotation on default KSA in scion-agents namespace
kubectl get serviceaccount default -n scion-agents -o yaml | grep gcp-service-account
# Expected: iam.gke.io/gcp-service-account: $PROJECT_NUMBER-compute@developer.gserviceaccount.com
# Verify the WI IAM binding
gcloud iam service-accounts get-iam-policy $GKE_GSA --project=$PROJECT_ID
# Expected: binding for serviceAccount:$PROJECT_ID.svc.id.goog[scion-agents/default]

5b. Settings.yaml Reference for GKE Dispatch

Section titled “5b. Settings.yaml Reference for GKE Dispatch”

Our settings.yaml only exposes GKE (remote) to ensure zero profile/runtime resolution conflicts:

schema_version: "1"
image_registry: us-central1-docker.pkg.dev/your-project/scion # WHERE agent images live
active_profile: remote # DEFAULT dispatch target
runtimes:
remote:
type: kubernetes
gke: true # true = CSI Secret Manager mounts
profiles:
remote:
runtime: remote # Maps to runtimes.remote (kubernetes)
gke Setting Secret Storage Requires CSI Add-on Secret Values in Pod Spec
false K8s native Secrets No Yes (in K8s Secret object)
true CSI → Secret Manager direct Yes No (fetched at mount time)

Use gke: true for production (better security posture — secret values never appear in Kubernetes objects). Use gke: false if the CSI add-on is not installed or for quick testing.

Understanding this flow is essential for debugging:

1. Hub dispatches agent pod to GKE
2. Hub mints IAP token via scion-transport SA
└─ Requires: roles/iam.serviceAccountTokenCreator ON scion-transport SA
3. Token injected as SCION_TRANSPORT_TOKEN env var in pod
4. sciontool in the pod reads SCION_TRANSPORT_TOKEN → "injected" mode
5. sciontool uses token as Authorization: Bearer for Hub API calls
6. IAP validates the token against the OAuth client ID
└─ Requires: scion-transport has roles/iap.httpsResourceAccessor
7. Hub processes the request

If step 2 fails silently (missing IAM binding): SCION_TRANSPORT_TOKEN is empty → sciontool skips OIDC → IAP rejects with "Invalid IAP credentials: empty token".


Terminal window
# Get IAP token
TOKEN=$(gcloud auth print-identity-token --audiences="$IAP_CLIENT_ID")
# Check Hub is reachable
curl -s "$HUB_URL/health" -H "Authorization: Bearer $TOKEN"
# Expected: {"status":"healthy",...}
# Dispatch a test agent
curl -s -X POST "$HUB_URL/api/v1/agents" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "e2e-test-1",
"template": "gemini-cli",
"task": "echo Hello from GKE"
}'
# Expected: HTTP 201 with agent details
Terminal window
# Wait 30-60 seconds for pod to schedule on GKE Autopilot
# (Autopilot may need to provision a new node — can take 1-2 minutes)
kubectl get pods -n scion-agents
# Expected: spawn-test--e2e-test-1-xxxx in Running or ContainerCreating state
Terminal window
# Get the pod name
POD=$(kubectl get pods -n scion-agents -o name | grep e2e-test-1 | head -1)
# Stream logs
kubectl logs -f -n scion-agents $POD
# Look for key indicators
kubectl logs -n scion-agents $POD --tail=100 \
| grep -i "transport\|IAP\|heartbeat\|error\|401\|403"

Success indicators:

Heartbeat succeeded
Transport token refresh scheduled

When redeploying the Hub with a new image:

  1. Scale Discord to min-instances=0 (prevents connection exhaustion):

    Terminal window
    gcloud run services update scion-discord \
    --min-instances=0 \
    --region=$REGION --project=$PROJECT_ID --quiet
  2. Sync workspace to latest before building:

    Terminal window
    git fetch origin main && git rebase origin/main
  3. Build with --ignore-file=.dockerignore (always):

    Terminal window
    gcloud builds submit . \
    --tag="$IMAGE_REGISTRY/hub:latest" \
    --ignore-file=.dockerignore \
    --project=$PROJECT_ID --quiet
  4. Deploy with --update-env-vars (not --set-env-vars):

    Terminal window
    # CORRECT — preserves all other env vars:
    gcloud run services update scion-hub \
    --update-env-vars="SCION_DEPLOY=$(date +%s)" ...
  5. Restore Discord to min-instances=1:

    Terminal window
    gcloud run services update scion-discord \
    --min-instances=1 --max-instances=1 \
    --region=$REGION --project=$PROJECT_ID --quiet

Run scion hub secret migrate-names against a hub deployed with this guide from a one-off Cloud Run job, as described in §9 of docs/deploy/migrate-names-cloudrun.md. The job uses the serving revision’s image digest and runs as scion-hub-runner. It reaches this guide’s public-IP Cloud SQL instance through the same Cloud Run Cloud SQL connection as the hub, so it needs no VPC configuration. The DSN is supplied only through Secret Manager: the job mounts the scion-hub-settings secret with --set-secrets, and no operator reads or handles the DSN. Do not run migrate-names from a workstation against this hub’s database.

The job takes the hub ID from server.hub.hub_id in that settings file (see Set a stable hub_id in §3c), and the command checks it against the hub’s existing secret records. Sections 1, 4, 5, 6 and 8 of the runbook apply to this guide’s hubs, with the differences listed in its §9; section 7 (Terraform cleanup) does not. See Secrets: IAM Permissions and Secret Naming for what the command does in general, and --help for its flags.


Appendix A: Complete settings.yaml Reference

Section titled “Appendix A: Complete settings.yaml Reference”
# === ROOT-LEVEL KEYS (not under server:) ===
schema_version: "1" # REQUIRED — must be first key
image_registry: us-central1-docker.pkg.dev/your-project/scion
active_profile: remote # Default dispatch runtime
# === RUNTIME DEFINITIONS ===
runtimes:
remote:
type: kubernetes
gke: true # CSI secret mounts
# === PROFILE MAPPINGS ===
profiles:
remote:
runtime: remote
# === SERVER CONFIGURATION ===
server:
database:
driver: postgres
url: postgres://scion:PASSWORD@/scionhub?host=/cloudsql/PROJECT:REGION:INSTANCE
auth:
mode: proxy
proxy:
provider: iap
iap:
audience: /projects/PROJECT_NUMBER/locations/REGION/services/scion-hub
transport:
mode: iap
oidc_audience: PROJECT_NUMBER-xxxx.apps.googleusercontent.com # OAuth client ID
platform_auth_sa: scion-transport@PROJECT_ID.iam.gserviceaccount.com
# === OIDC IDENTITY PROVIDER ===
# Enables the Hub to act as an OIDC provider, publishing JWKS endpoints
# and minting identity tokens for agents to authenticate to external resources.
oidc:
enabled: true # Enabled to support agent identity tokens
token_lifetime: 15m # Lifetime of minted OIDC identity tokens
# === OIDC FEDERATION ===
# Enables inbound federation from trusted external OIDC providers.
federation:
enabled: false # Disabled by default, configure if needed
trusted_issuers: []
secrets:
backend: gcpsm
gcp_project_id: your-project
hub:
hub_id: hub-ha-prod # Stable identity — see Section 3c
admin_emails:
- admin@example.com
hub_name: hub-ha-prod
storage:
provider: gcs
bucket: scion-hub-your-project
broker:
enabled: true
host: 127.0.0.1

Appendix B: Troubleshooting Quick Reference

Section titled “Appendix B: Troubleshooting Quick Reference”
Terminal window
IAP_CLIENT_ID="your-oauth-client-id.apps.googleusercontent.com"
TOKEN=$(gcloud auth print-identity-token --audiences="$IAP_CLIENT_ID")
Terminal window
# Decode a JWT to inspect claims
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
# Check: aud, iss, email, exp
Terminal window
HUB="https://scion-hub-PROJECT_NUMBER.REGION.run.app"
# Health check
curl -s "$HUB/health" -H "Authorization: Bearer $TOKEN"
# List agents
curl -s "$HUB/api/v1/agents" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# Dispatch agent
curl -s -X POST "$HUB/api/v1/agents" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"test-1","template":"gemini-cli","task":"echo hello"}'

4. Diagnostic Log Patterns & Opaque Failures

Section titled “4. Diagnostic Log Patterns & Opaque Failures”

Volume Provisioning PVC Failure (pods "..." not found)

Section titled “Volume Provisioning PVC Failure (pods "..." not found)”
  • Symptoms: Handlers return a generic dispatch timeout: failed to launch container: pods "test-project--test22" not found.
  • Cause: Your Hub project includes a default scratchpad shared directory, which mounts a ReadWriteMany PVC. If GKE Autopilot only has the default standard-rwo storage class (which only supports ReadWriteOnce), GKE Autopilot fails to bind the volume and immediately destroys the pod.
  • Diagnosis: Run kubectl get pvc -n scion-agents and kubectl describe pvc -n scion-agents. Look for: VolumeCapabilities is invalid: specified multi writer with mount access type.
  • Resolution: Delete/disable the scratchpad shared directory from your project settings in the Hub UI, or provision a Google Cloud Filestore backend to support ReadWriteMany PVC mounts.

Pull Image Not Yet Implemented (cloudrun: PullImage not yet implemented)

Section titled “Pull Image Not Yet Implemented (cloudrun: PullImage not yet implemented)”
  • Symptoms: The Hub returns a PullImage not yet implemented error.
  • Cause: The runtime broker has incorrectly attempted to route your dispatch request to a Cloud Run (cr) backend instead of your GKE (remote) backend.
  • Resolution: Remove any cr / cloudrun profile or runtime mappings from your settings.yaml secret in Secret Manager, leaving GKE (remote) as the sole configured profile.
  • Symptoms: You updated admin_emails in settings.yaml and redeployed, but the added user accounts still cannot perform administrative tasks in the Hub UI.
  • Cause: Once the Hub database is initially booted, all YAML-configured Hub settings are written to the database. These database settings permanently supersede future YAML changes.
  • Resolution: Explicitly update the settings through the Hub Admin UI, or manually modify the settings table inside your Cloud SQL PostgreSQL database.
Log Line Root Cause Fix
Heartbeat failed: ... empty token Transport token not injected Check serviceAccountTokenCreator IAM (Section 2c)
Heartbeat failed: ... 403: Access denied Transport SA lacks IAP role Grant iap.httpsResourceAccessor (Section 2e)
dial tcp 127.0.0.1:8080: connection refused Hub endpoint is localhost Upgrade Scion CLI to the latest version
driver name secrets-store.csi.x-k8s.io not found Wrong CSI driver name on GKE GKE requires secrets-store-gke.csi.k8s.io. Ensure gke: true is configured in settings.yaml.
PermissionDenied: secretmanager.versions.access WI binding missing Check GKE Workload Identity bindings (Section 2g)
SQLSTATE 53300 Too many DB connections Scale dependent services (like Discord) to 0 instances before upgrading.