Skip to content

Install on Google Cloud

The GCP install path uses the Helm chart at deploy/helm/x1agent/, fed by the values you captured with mise run configure:prod. Secrets live in Google Secret Manager (GSM), synced into the cluster by External Secrets Operator (ESO). TLS comes from cert-manager + Let’s Encrypt (DNS-01 challenge against Cloud DNS).

v1 scope: the Terraform module at deploy/terraform/gcp/ provisions the cluster, IAM, GSM secret resources, Artifact Registry, DNS zone, and global static IP. Helm-side, the chart assumes ESO is installed cluster-wide (one operator step between the two terraform applies — see Sequence below).

  • A GCP project with billing enabled (the Terraform module enables APIs but does not create projects)
  • gcloud, kubectl, helm, terraform (>= 1.3), and bun on PATH
  • mise run configure:prod already completed with CLOUD_PROVIDER=gcp (writes the project ID, account, base domain, and the bare-minimum non-secret config to installs/<base-domain>.local)

Everything else (cluster, IAM, GSM placeholders, Artifact Registry, static IP, DNS zone) is provisioned by the Terraform module — see Sequence below.

For most operators the four-verb lifecycle is enough — see Lifecycle for the operator’s-eye view:

Terminal window
mise run configure:prod # capture cloud target, base domain, admin emails
mise run plan:prod # preview terraform + helm changes (read-only)
mise run install:prod # one-shot bootstrap — terraform → ESO → secrets → images → helm
mise run status:prod # confirm pods + ingress + cert

install:prod runs the two-pass Terraform apply, installs ESO + cert-manager, populates GSM placeholders, builds + pushes images, and helm installs the chart. There are a couple of manual steps it doesn’t do (no platform should: you need to set DNS records and, depending on chart version, paste a few secrets into GSM). They’re laid out in detail below.

If you’d rather drive the phases yourself (or need to recover from a partial install), here’s what install:prod runs under the hood:

Terminal window
# 1. Capture install values
mise run configure:prod
# 2. Provision GCP-side infra in two passes (ESO CRDs need to exist
# before the ClusterSecretStore manifest applies)
mise run terraform:prod:init
mise run terraform:prod:apply:cluster # cluster + IAM + GSM + AR + DNS
# 3. Get cluster credentials, install ESO, annotate its SA for WI
gcloud container clusters get-credentials x1agent --region us-central1 --project <project>
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
-n external-secrets --create-namespace --set installCRDs=true
kubectl -n external-secrets annotate sa external-secrets \
iam.gke.io/gcp-service-account=x1agent-eso@<project>.iam.gserviceaccount.com
kubectl -n external-secrets rollout restart deploy/external-secrets
# 4. Second terraform apply — adds the ClusterSecretStore now ESO CRDs exist
mise run terraform:prod:apply
# 5. Populate GSM secrets (each one created empty — values never touch
# Terraform state). At minimum:
echo -n "$ANTHROPIC_API_KEY" | gcloud secrets versions add x1agent-anthropic-api-key \
--project=<project> --data-file=-
# Repeat for: x1agent-jwt-secret, x1agent-api-internal-token,
# x1agent-postgres-password, plus any optionals you actually use.
# 6. Build + push images, then helm install via the installer
mise run install:prod:plan
mise run install:prod:apply
# 7. Watch status until ingress IP + cert are ready
mise run status:prod
# 8. Set NS records at your registrar pointing at the Cloud DNS zone
# (terraform output: dns_nameservers)

mise run install:prod:build-images (which calls deploy/scripts/build-push-prod.sh) builds and pushes seven images to your Artifact Registry:

ImageDockerfileUsed by
apideploy/docker/api.prod.Dockerfileapi Deployment, migrate Job
appdeploy/docker/app.prod.Dockerfileapp Deployment
previewdeploy/docker/preview.prod.Dockerfilepreview provider
graph-surrealdbdeploy/docker/graph-surrealdb.prod.Dockerfilegraph provider
mcp-oauth-proxydeploy/docker/mcp-oauth-proxy.prod.Dockerfileper-attached remote_oauth MCP
agentpackages/agent/Dockerfilesession pods
sidecarpackages/sidecar/Dockerfilesession pods (sidecar container)

The installer’s install:prod (and the subsequent deploy:prod) wraps this for you. Run mise run install:prod:build-images only when you want to rebuild without re-applying the chart.

Pass the tag to the installer via INSTALL_IMAGE_TAG=$TAG mise run install:prod:plan. If unset, the installer uses the current git short-SHA.

mise run install:prod:plan

This:

  1. Verifies preflight (gcloud auth present, .env.local present, helm/kubectl/gcloud on PATH)
  2. Renders deploy/helm/x1agent/values.<baseDomain>.yaml from installs/<base-domain>.local
  3. Runs helm template and reports how many resources would be created

No cluster mutation. Safe to run repeatedly. The values file is regenerated each time, so .env.local changes flow through.

mise run install:prod:apply

Confirms once, then runs:

helm upgrade --install x1agent deploy/helm/x1agent \
-f deploy/helm/x1agent/values.<baseDomain>.yaml \
--namespace x1agent --create-namespace --wait --timeout 10m

TLS provisioning is async — the chart creates Certificate CRs that cert-manager fulfils via Let’s Encrypt’s DNS-01 challenge against Cloud DNS. First-time provisioning typically takes 1–10 minutes. Pods come up before the cert is ready; HTTPS will fail until then. (Switch to the *-staging ClusterIssuer during iteration to avoid Let’s Encrypt’s 5-duplicate-cert/week rate limit.)

mise run status:prod

Prints:

  • Each Deployment’s ready/desired replica count
  • The Ingress’s allocated IP (or (pending))
  • The Certificate Ready condition, per host

Run it on a loop while waiting for first-time cert provisioning.

mise run destroy:prod

Double-confirms, then helm uninstall + terraform destroy. The in-cluster Postgres PVC is deleted with the StatefulSet — there are no GSM-backed backups in v1, so this is a one-way operation. Take a pg_dump first if you care about the data.

The installer’s render step converts these installs/<base-domain>.local values into Helm overrides:

.env.local keyHelm pathNotes
BASE_DOMAINbaseDomainURLs derived in templates
GCP_PROJECT_IDcloud.gcp.projectIdWorkload Identity GSA derived from this
GCP_REGIONcloud.gcp.regionDefault us-central1
ARTIFACT_REGISTRYcloud.gcp.artifactRegistry + each images.*.repositoryDefault <region>-docker.pkg.dev/<project>/x1agent
PLATFORM_ADMIN_EMAILSconfig.PLATFORM_ADMIN_EMAILSInline env on api pod
ALLOWED_DOMAINSconfig.ALLOWED_DOMAINSInline env on api pod

Secrets (ANTHROPIC_API_KEY, JWT_SECRET, etc.) are NOT in values.yaml. They live in GSM and ESO syncs them into a K8s Secret/x1agent-secrets that the api envFroms.

CLOUD_PROVIDER=gcp is currently the only option. The wizard, the chart, and the installer are all written so adding aws or azure is a matter of:

  1. Adding the option to configure’s cloud-provider select
  2. Templating cloud-specific bits in the chart (Workload Identity → IRSA, managed cert → ACM, GSM → AWS SM)
  3. Adding a new secret-store kind in the ExternalSecret bindings
  4. Following with a Terraform module under deploy/terraform/<provider>/

The BASE_DOMAIN + URL derivation pattern is provider-agnostic.