Self-hosting

Install on GKE

Install Agent Relay on an existing Google Kubernetes Engine cluster in 15 numbered steps, each with a check before you move on.

Agent version of this guide · Markdown

Run the steps in order. Copy each block, replace every <...>, and check the Done when line before you move on. Expect about an hour and a half, most of it waiting on Google Cloud: the HTTPS certificate alone can take up to an hour.

Release: agent-relay-2026.11.1 (infra commit c6087c2f, chart 0.6.53). Tested on GKE end to end, on a private regional cluster (Kubernetes 1.35, Workload Identity, a private control plane). Read Known limits before a production rollout: transcript reads need the next release.

You need:

  • Owner in the Google Cloud project (or the roles listed under Who runs the install), and kubectl admin on the GKE cluster;
  • whoever manages DNS for your domain (step 6);
  • the person who will be the first admin (step 15).

The cluster: GKE Standard with Workload Identity on, the HTTP load balancing add-on on (the default), in a VPC with Private Services Access configured: Cloud SQL and Memorystore get private IPs from it. For nodes, plan on at least 3 × e2-standard-4 with 100 GB disks. Small nodes with 20 GB disks run out of ephemeral storage while pulling the images.

Network:

  • Steps 1, 4, 5 and 6 need only Google Cloud access, not kubectl. Start them while cluster access is being sorted out.
  • Nothing connects to the database from your machine. Every database step runs as a short-lived Job inside the cluster, so Cloud SQL needs no public IP.

1. Get the files

mkdir agent-relay-install && cd agent-relay-install
base=https://agentrelay.com/self-hosting/files/gke
curl -fsSLO "$base/SHA256SUMS"
for f in $(awk '{print $2}' SHA256SUMS); do curl -fsSLO "$base/$f"; done
shasum -a 256 -c SHA256SUMS
git clone https://github.com/AgentWorkforce/infra.git
git -C infra checkout agent-relay-2026.11.1
test "$(git -C infra rev-parse HEAD)" = c6087c2fc6daee5359900b18da78c2522ffd1526 && echo "release ok"
mkdir release && gh release download agent-relay-2026.11.1 -R AgentWorkforce/infra -D release
(cd release && shasum -a 256 -c SHA256SUMS && tar -xzf agent-relay-0.6.53.tgz) | tail -1

No gh? Download the same assets from the release page into release/.

Tools: gcloud (with gke-gcloud-auth-plugin), terraform 1.11 or later (or OpenTofu), helm 3, kubectl, jq, yq, openssl, python3, and crane for step 5. Cloud Shell has everything except crane and a new enough Terraform.

Done when: every shasum line in the install folder ends in OK, release ok prints, and the last line is release-notes.md: OK.

2. Connect to Google Cloud and the cluster

gcloud auth login && gcloud auth application-default login
gcloud config set project <project id>
gcloud services enable container.googleapis.com sqladmin.googleapis.com servicenetworking.googleapis.com \
  redis.googleapis.com pubsub.googleapis.com secretmanager.googleapis.com dns.googleapis.com \
  artifactregistry.googleapis.com compute.googleapis.com
gcloud container clusters get-credentials <cluster> --location <region>   # add --internal-ip for a private endpoint
kubectl auth can-i '*' '*' --all-namespaces
gcloud container clusters describe <cluster> --location <region> \
  --format='value(workloadIdentityConfig.workloadPool,addonsConfig.httpLoadBalancing.disabled)'
gcloud container node-pools list --cluster <cluster> --location <region> \
  --format='table(name,config.workloadMetadataConfig.mode)'
gcloud compute addresses list --global --filter='purpose=VPC_PEERING' --format='value(name,network)'
kubectl get storageclass

Done when:

  • can-i prints yes;
  • the describe line prints <project id>.svc.id.goog and nothing after it (HTTP load balancing is not disabled);
  • every node pool shows GKE_METADATA;
  • the addresses line lists a Private Services Access range on your cluster's VPC;
  • one StorageClass is marked (default).

Private control plane? Use the cluster's DNS endpoint (get-credentials --dns-endpoint), or run the install from a machine inside the VPC with --internal-ip.

3. Using your own Cloud SQL, bucket or other resources? (skip if Terraform creates them)

Follow Bring your own Google Cloud resources instead of step 4, then continue at step 5.

4. Create the Google Cloud resources (Terraform, about 5 minutes)

This creates a regional Cloud SQL for PostgreSQL 17 instance (private IP only, Google-managed shared CA, admin password in Secret Manager), the sessions bucket, a Standard HA Memorystore for Redis with TLS, a Pub/Sub topic and subscription with a dead-letter queue, a private DNS zone that resolves the Cloud SQL certificate name inside your VPC, and the Workload Identity grants. Every grant is on one resource, never on the project.

cd infra/terraform/examples/gcp
cat > terraform.tfvars <<'EOF'
project_id           = "<project id>"
region               = "<cluster region, e.g. us-central1>"
name                 = "agent-relay"
namespace            = "agent-relay"
release_name         = "agent-relay"
network_self_link    = "projects/<project id>/global/networks/<cluster VPC>"
sessions_bucket_name = "<globally unique bucket name, e.g. acme-agent-relay-sessions>"
deletion_protection  = true
# kms_key_name = "projects/<p>/locations/<region>/keyRings/<ring>/cryptoKeys/<key>"   # optional CMEK
labels = { app = "agent-relay" }
EOF
grep -n '<' terraform.tfvars
terraform init
terraform plan -out tfplan
terraform apply tfplan
terraform output -json chart_values > ../../../../managed-values.json
cd -

Done when: the grep prints nothing, the plan shows 27 to add, 0 to change, 0 to destroy, the apply completes, and managed-values.json exists.

  • Error 403 ... servicenetworking or no private services access connection: the VPC has no Private Services Access. Your network team configures it on the cluster's VPC; then apply again.
  • CMEK: first grant the Cloud SQL, Cloud Storage and Pub/Sub service agents roles/cloudkms.cryptoKeyEncrypterDecrypter on the key (see Bring your own Google Cloud resources).

5. Mirror the images to Artifact Registry

Copy the release images, by digest, into your own registry. Run this from Cloud Shell or a VM in Google Cloud: there it takes about a minute, from a laptop it can take half an hour.

REGION=<region>; PROJECT=<project id>; REPO=agent-relay-mirror
gcloud artifacts repositories create $REPO --repository-format=docker --location=$REGION
export MIRROR_ROOT=$REGION-docker.pkg.dev/$PROJECT/$REPO
gcloud auth print-access-token | crane auth login $REGION-docker.pkg.dev -u oauth2accesstoken --password-stdin
for ref in $(jq -r '.images[].ref' release/release-manifest.json); do
  crane copy "$ref" "$MIRROR_ROOT/${ref%@*}"
  [ "$(crane digest "$MIRROR_ROOT/$ref")" = "${ref#*@}" ] && echo "ok ${ref%@*}"
done
sed "s|__IMAGE_MIRROR_ROOT__|$MIRROR_ROOT|g" release/release-images.values.yaml > release-images.values.yaml
grep -c "$MIRROR_ROOT" release-images.values.yaml

The GKE node service account must be able to pull from this repository (roles/artifactregistry.reader on it). The default Compute Engine service account usually already can.

Done when: eight ok lines, and the last line prints a number above 10.

The release has eight images. A GKE install runs five of them (control API, dashboard, Relaycast, the sessions services and the PostgreSQL client); the other three are for other profiles, and mirroring them is harmless.

6. Reserve the IP address and point DNS at it (DNS owner)

The HTTPS certificate is a Google-managed certificate. Google issues it only after all four names resolve to the load balancer's address, so create the records now:

gcloud compute addresses create agent-relay-ingress --global --ip-version IPV4
gcloud compute addresses describe agent-relay-ingress --global --format='value(address)'

Create A records for <domain>, api.<domain>, cast.<domain> and history.<domain>, all pointing at that address.

Done when: dig +short api.<domain> prints the address, for all four names.

CAA records on your domain? They must allow pki.goog and letsencrypt.org.

7. Create the settings file

cat > settings.sh <<'EOF'
export DOMAIN=<app hostname, e.g. ar.example.com>
export ORG_ID=<stable id, e.g. org_acme: never change it>
export ORG_NAME='<organization name>'
export ADMIN_EMAIL=<first admin's email>
export GCP_PROJECT_ID=<project id>
export REGION=<region>
export MIRROR_ROOT=<region>-docker.pkg.dev/<project id>/agent-relay-mirror
export NAMESPACE=agent-relay
export HELM_RELEASE=agent-relay
export STATIC_IP_NAME=agent-relay-ingress
export SERVICES_IMAGE=$MIRROR_ROOT/ghcr.io/agentworkforce/sessions-pipeline@sha256:aa8d86854db28debe326bcffd705e44ae93e20f40f587d80d635bc3b404ed117
export PSQL_IMAGE=$MIRROR_ROOT/ghcr.io/cloudnative-pg/postgresql@sha256:ed6923f9ba2399cf9070379a353c710c29ced8a294be73986a2c3dae7e599ce3
export INSTALL_DIR="$PWD"   # run "source settings.sh" from the agent-relay-install folder
export CHART="$INSTALL_DIR/release/agent-relay"
TF="$INSTALL_DIR/infra/terraform/examples/gcp"
PG="$(terraform -chdir="$TF" output -json postgres)"
export CLOUD_SQL_INSTANCE="$(jq -r '.connection_name | split(":")[2]' <<<"$PG")"
export CLOUD_SQL_HOST="$(jq -r .dns_name <<<"$PG")"
export CLOUD_SQL_ADMIN_USER="$(jq -r .admin_username <<<"$PG")"
export CLOUD_SQL_ADMIN_SECRET_ID="$(jq -r .admin_password_secret_id <<<"$PG")"
export CLOUD_SQL_CA_SHA256="$(jq -r '.["sessions-pipeline"].worker.postgresCa.sha256' "$INSTALL_DIR/managed-values.json")"
export SESSIONS_BUCKET="$(jq -r '.["sessions-pipeline"].worker.locker.bucket' "$INSTALL_DIR/managed-values.json")"
unset PG TF
EOF
source settings.sh
bash -c 'source ./settings.sh; for v in DOMAIN ORG_ID ORG_NAME ADMIN_EMAIL GCP_PROJECT_ID REGION MIRROR_ROOT CLOUD_SQL_INSTANCE CLOUD_SQL_HOST CLOUD_SQL_ADMIN_USER CLOUD_SQL_ADMIN_SECRET_ID CLOUD_SQL_CA_SHA256 SESSIONS_BUCKET; do [ -n "${!v}" ] && [ "${!v}" != null ] && [[ "${!v}" != *"<"* ]] || echo "MISSING: $v"; done; echo "settings checked"'

Brought your own resources (step 3)? Replace the six lines between PG= and unset with the values from Bring your own Google Cloud resources.

Done when: only settings checked prints.

In every new terminal, first run cd agent-relay-install && source settings.sh.

8. Set up the databases

bash cloud-sql-setup.sh pre

It creates the namespace, the cloud-sql-ca ConfigMap, the three databases and their roles (in a Job inside the cluster), and writes every database credential to ~/.agent-relay-install (mode 0700). It reads the Cloud SQL admin password from Secret Manager and never prints it.

Done when: it ends with pre done, after tls: true and extensions: btree_gin,pg_trgm,plpgsql,vector.

Copy ~/.agent-relay-install to your secret store: it holds the database passwords. Never commit it.

  • differs from CLOUD_SQL_CA_SHA256: managed-values.json is from an older apply. Rerun the last Terraform command of step 4, then source settings.sh.
  • The Job is evicted (low on resource: ephemeral-storage): the nodes' disks are too small (see "The cluster" above).
  • Run pre once, before step 11. After step 13 it refuses to run again, by design.

9. Fill in values.yaml

sed -i.orig -e "s|__NAMESPACE__|$NAMESPACE|g" -e "s|__ORG_ID__|$ORG_ID|g" \
  -e "s|__ORG_NAME__|$ORG_NAME|g" -e "s|__ADMIN_EMAIL__|$ADMIN_EMAIL|g" -e "s|__DOMAIN__|$DOMAIN|g" \
  -e "s|__REGION__|$REGION|g" -e "s|__SESSIONS_BUCKET__|$SESSIONS_BUCKET|g" \
  -e "s|__STATIC_IP_NAME__|$STATIC_IP_NAME|g" values.yaml
grep -n '__[A-Z_]*__' values.yaml

Done when: the only line printed is __LLM_MODEL__. You set that one later, when you turn on titles and summaries.

10. Create the secrets

This generates the remaining keys and creates the five Kubernetes Secrets. Nothing is printed.

S=~/.agent-relay-install/secret-files
( umask 077
for k in selfhost-bootstrap-token selfhost-setup-key relaycast-internal-secret \
         relaycast-workspace-bootstrap-secret relaycontrol-introspect-secret session-read-api-secret \
         selfhost-encryption-key; do
  [ -s "$S/agent-relay-secrets/$k" ] || openssl rand -hex 32 | tr -d '\n' > "$S/agent-relay-secrets/$k"
done
[ -s "$S/agent-relay-secrets/SUMMARY_MASTER_KEYS" ] || printf 'k1:%s' "$(openssl rand -hex 32)" > "$S/agent-relay-secrets/SUMMARY_MASTER_KEYS"
cp "$S/agent-relay-secrets/relaycontrol-introspect-secret" "$S/agent-relay-ingest-edge-runtime/introspect-secret" )
for name in agent-relay-secrets agent-relay-read-api agent-relay-ingest-edge-runtime \
            search-indexer-runtime search-indexer-owner; do
  kubectl -n $NAMESPACE create secret generic "$name" --from-file="$S/$name" \
    --dry-run=client -o yaml | kubectl apply -f -
done

Done when: five lines end in created. Copy ~/.agent-relay-install to your secret store again: it now holds the encryption keys.

Keep your secrets in SOPS (for example with Cloud KMS) or Google Secret Manager? Use one of the options in Secrets instead of this block. vals with Google Secret Manager is tested on GKE.

11. Install with Helm

helm upgrade --install $HELM_RELEASE "$INSTALL_DIR/release/agent-relay-0.6.53.tgz" --namespace $NAMESPACE \
  -f "$CHART/values-gcp.yaml" -f "$CHART/values-gcp-native.yaml" \
  -f "$INSTALL_DIR/managed-values.json" -f "$INSTALL_DIR/values.yaml" \
  -f "$INSTALL_DIR/release-images.values.yaml" --timeout 25m

The order matters: the two GCP profiles, then Terraform's managed services, then your site values, then the release images last. Without release-images.values.yaml you would get the previous release's images.

Done when: it prints STATUS: deployed. Don't add --wait.

12. Wait for the database migrations

for j in $(kubectl -n $NAMESPACE get jobs -o name | grep -E 'relay-control-migrate|relaycast-migrate|agent-relay-worker-migrate'); do
  kubectl -n $NAMESPACE wait --for=condition=complete "$j" --timeout=20m
done

Done when: three lines say condition met (about 2 minutes).

Expected at this point: the read API, ingest edge, search indexer and the search-indexer-bootstrap Job fail. Step 13 fixes them.

13. Switch on the service logins

bash cloud-sql-setup.sh post
kubectl -n $NAMESPACE rollout restart deploy/agent-relay-read-api deploy/agent-relay-ingest-edge deploy/agent-relay-search-indexer
for j in $(kubectl -n $NAMESPACE get jobs -o name | grep search-indexer-bootstrap); do kubectl -n $NAMESPACE delete "$j"; done
helm upgrade --install $HELM_RELEASE "$INSTALL_DIR/release/agent-relay-0.6.53.tgz" --namespace $NAMESPACE \
  -f "$CHART/values-gcp.yaml" -f "$CHART/values-gcp-native.yaml" \
  -f "$INSTALL_DIR/managed-values.json" -f "$INSTALL_DIR/values.yaml" \
  -f "$INSTALL_DIR/release-images.values.yaml" --timeout 25m
kubectl -n $NAMESPACE wait --for=condition=complete "$(kubectl -n $NAMESPACE get jobs -o name | grep search-indexer-bootstrap)" --timeout=10m
kubectl -n $NAMESPACE rollout status deploy --timeout=10m
kubectl -n $NAMESPACE get pods

Done when:

  • post done prints after three login ok ... tls=true lines;
  • the bootstrap Job shows condition met;
  • every pod is Running and ready (completed Jobs show Completed).

PostgresTlsConfigError in the read API, ingest edge or indexer logs: the cloud-sql-ca ConfigMap doesn't match the pin in managed-values.json. Compare kubectl -n $NAMESPACE get cm cloud-sql-ca -o jsonpath='{.data.ca\.crt}' | sha256sum with echo $CLOUD_SQL_CA_SHA256.

14. Wait for the certificate and check (up to 60 minutes)

kubectl -n $NAMESPACE get managedcertificate agent-relay -o jsonpath='{.status.certificateStatus}{"\n"}'

Repeat until it prints Active. Then:

curl -fsS "https://api.$DOMAIN/readyz" && echo " api ok"
curl -fsS "https://cast.$DOMAIN/health" && echo " cast ok"
curl -fsS -o /dev/null -w "dashboard %{http_code}\n" "https://$DOMAIN/"
curl -s -o /dev/null -w "history %{http_code}\n" "https://history.$DOMAIN/v1/sessions"

Done when: Active, then api ok, cast ok, dashboard 200 and history 401.

  • Still Provisioning after an hour: one of the four names doesn't resolve to the address from step 6, or a CAA record blocks Google. Fix DNS; the certificate retries by itself.
  • FailedNotVisible: the same: DNS doesn't point at the load balancer yet.
  • 502 for a few minutes after Active: the load balancer's health checks are still converging. Wait 5 minutes.

15. First sign-in, then add users

The first admin does this before signing in any other way, including from the desktop app:

kubectl -n $NAMESPACE get secret agent-relay-secrets -o jsonpath='{.data.selfhost-setup-key}' | base64 -d; echo
  1. Open https://<domain>/login and choose First-time setup: enter setup key.
  2. Paste the key and choose a password.

Then add users:

bash add-user.sh "$DOMAIN"

It prints a temporary password for each user; they choose their own at first sign-in.

Done when: the admin is signed in to the dashboard.

Next

  • Before people start: in the dashboard, under Settings › Policies, set what the desktop app uploads.
  • Desktop app: Desktop app rollout.
  • Search: transcript search is stored in Cloud SQL (search.backend: postgres); there is no search cluster to run.
  • Problems: Troubleshooting, then Support.

Known limits

What release agent-relay-2026.11.1 does not yet qualify on GKE:

  1. Transcript reads fail on Cloud Storage. The 2026.11.1 sessions image records every upload as stored in S3, even when it is in Cloud Storage, so the read API refuses those records: GET /v1/sessions/<id>/turns and the other transcript reads answer 501 turns_canonical_pointer_invalid. Uploads, event reads and search indexing work, and EKS is not affected. The fix is merged and ships in the sessions image of the next release after 2026.11.1, with a backfill for sessions already uploaded. Don't roll out to people who need transcript reads before then.
  2. The release image overlay is the EKS one. The images are cloud-neutral and this guide applies the overlay on GKE, which the test proved, but the release pipeline doesn't produce or check a GKE overlay yet.
  3. No automated install or upgrade on GKE. The release upgrader supports only EKS. On GKE the install is the manual sequence above, and upgrades are manual Helm upgrades with a Cloud SQL backup first.
  4. Search runs in PostgreSQL, not OpenSearch. Fine for a first install; search at large scale on GKE isn't load tested.
  5. Memorystore without AUTH. The services can't send Redis credentials, so the instance uses TLS with AUTH off. Keep it reachable only from the cluster's VPC.
  6. Not highly available. The control API, dashboard, Relaycast and search indexer each run one replica, as on EKS.
  7. Not covered by the test: customer-managed encryption keys, GKE Autopilot, Shared VPC, single sign-on, titles and summaries with a model, and a live desktop app upload (the upload path was proven with a test driver).

Stuck? See troubleshooting or support.