Deploy on GKE with Argo CD
This page installs Elitea on Google Kubernetes Engine from one Argo CD Application that syncs one chart, oci://ghcr.io/elitea-ng/charts/elitea. The chart carries everything that runs in the cluster: the platform, NATS JetStream and its stream bootstrap, the vector store (elitea-vector and the bundled Qdrant), DeepWiki, Inventory and the GKE edge objects. Each feature is on by default and is one switch (<feature>.enabled: false leaves it out). No static cloud credential exists anywhere: object storage and the model providers authenticate as Google service accounts, and every secret comes from Google Secret Manager.
It is the GitOps path through the same install as Deploy from scratch and Deploy on Google Cloud (GKE). Those pages explain each choice; this one lists what must exist, the commands that create it, and the order of the first sync. The examples it refers to are in deploy/argocd/examples/gke/, written to be copied into a GitOps repository your organisation owns.
flowchart LR
Proxy["Optional CDN / proxy"] --> LB["Google external Application Load Balancer"]
LB -->|"one rule: / (container-native)"| Main["elitea-main"]
Main -->|"/app, /docs"| Web["elitea-web"]
Main --> GW["elitea-llm-gateway"]
GW -->|"Workload Identity"| Vertex["Vertex AI"]
Main -->|"Workload Identity"| GCS[("GCS bucket")]
Main --> SQL[("Cloud SQL, private IP, SSL")]
ESO["External Secrets Operator"] -->|"reads"| SM[("Secret Manager")]
ESO -->|"writes Secrets"| MainPreconditions
Section titled “Preconditions”The chart creates none of these. Each one fails the first sync, or a pod soon after it, when it is missing.
| Precondition | What it must be | Commands |
|---|---|---|
| GKE cluster | Dataplane V2, Workload Identity Federation, private nodes, Cloud NAT | The cluster |
| cert-manager | installed; the chart makes the elitea-internal-ca and elitea-runtime-ca Issuers (certManager.issuers.create: true in values-gke.yaml) | cert-manager |
| Cloud SQL | PostgreSQL 16+, private IP, “Allow only SSL connections”, role and two databases created by hand | Cloud SQL |
| GCS bucket and service accounts | a dedicated bucket, the elitea-main service account with roles/storage.admin on that bucket only | GCS and service accounts |
| DNS and TLS | a global static IP, a DNS record for it, a cert-manager issuer for the public certificate | Edge |
| External Secrets Operator | installed, with a ClusterSecretStore on Secret Manager | Secrets |
| Argo CD | installed, with the chart registry registered | Argo CD |
| KEDA | installed once for the cluster (or keda.enabled: true in a cluster that has none). Without it the worker scales on CPU | Autoscaling |
| Capacity | about 4 vCPU and 10 GiB of requests for the release; no container requests less than 50m CPU (the GKE Autopilot minimum) | Budget |
The commands use these names. Change them consistently:
PROJECT_ID=<your project>REGION=<region> # for example europe-west1NS=eliteaThe cluster
Section titled “The cluster”Create a GKE Standard or Autopilot cluster with Dataplane V2 (--enable-dataplane-v2; it enforces the chart’s NetworkPolicies, ipBlock included), Workload Identity Federation (--workload-pool=$PROJECT_ID.svc.id.goog) and private nodes. Give the private nodes egress through Cloud NAT, for ghcr.io, where the images are published, and for the model providers:
gcloud compute routers create elitea-nat-router --network <VPC> --region "$REGION"gcloud compute routers nats create elitea-nat --router elitea-nat-router \ --region "$REGION" --auto-allocate-nat-external-ips --nat-all-subnet-ip-rangesThe cluster on the Google Cloud page has the node sizing and the NodeLocal DNSCache caveat.
cert-manager
Section titled “cert-manager”Install cert-manager cluster-wide. The NATS CA, every NATS identity, the LLM gateway’s, DeepWiki’s and Inventory’s mTLS hops, elitea-vector, Qdrant’s TLS and the providers’ callback client certificates are cert-manager Certificates; you mint none of them by hand. values-gke.yaml sets certManager.issuers.create: true, so the chart also makes the two namespaced Issuers that every mTLS block names (certManager.issuers.internal and .runtime), in wave -15:
elitea-internal-ca: a self-signed Issuer, a CACertificate(ECDSA P-256, five years) and theelitea-internal-caIssuer over it; nothing leaves the cluster. It issues the gateway, DeepWiki and Inventory hops.elitea-runtime-ca: a CA Issuer over the existing Secretelitea-runtime-ca(certManager.issuers.runtime.caSecretName). It issues elitea-vector’s pair, Qdrant’selitea-qdrant-tlsand theelitea-deepwiki-callback-client/elitea-inventory-callback-clientcertificates. The chart never generates this CA: the worker certificate and the runtime listeners in Secret Manager are signed by it. Withcreate: truethe chart lists the Secret as an ExternalSecret (runtime-ca.crtandruntime-ca.key), so the Issuer turns Ready on the first sync.
Already made the two Issuers by hand, as in Deploy from scratch, Step 1? Set certManager.issuers.create: false, and set certManager.issuers.internal.kind and .runtime.kind to Issuer. A block’s own issuerRef still wins over the shared setting.
The NATS CA is the chart’s own (elitea-nats-ca, wave -10); you create nothing for it. A cert-manager ClusterIssuer for the public certificate (ACME, or the origin CA issuer of a proxy in front) is the third thing cert-manager needs; see Edge.
Cloud SQL
Section titled “Cloud SQL”Create a PostgreSQL 16 or newer instance with a private IP on the cluster’s VPC (private services access) and “Allow only SSL connections”:
gcloud sql instances create elitea-pg --database-version POSTGRES_16 \ --region "$REGION" --network "projects/$PROJECT_ID/global/networks/<VPC>" \ --no-assign-ip --ssl-mode ENCRYPTED_ONLY \ --tier db-custom-4-16384values-gke.yaml sizes for this tier: 4 vCPU and 16 GB, where Cloud SQL sets max_connections to 500 from the memory size (postgresql.maxConnections: 500). The chart adds up every component at its replica ceiling (elitea-main, the gateway, the scheduler, DeepWiki, Inventory and the worker’s agent state: 359) and refuses a release that does not fit in 500 minus the 25 it reserves. If you set --database-flags max_connections=… yourself, state the same number in postgresql.maxConnections. Scaling has the formula.
Cloud SQL has no superuser, so prepare the role and both databases once by hand. Choose the role’s password; it goes into both DSNs in the first of the steps:
gcloud sql users create elitea_v2 --instance elitea-pg \ --password '<password>'Then, connected as postgres:
GRANT elitea_v2 TO postgres;CREATE DATABASE elitea_v2 OWNER elitea_v2;CREATE DATABASE elitea_v2_agentstate OWNER elitea_v2;values-gke.yaml keeps the chart’s database-preparation hook off and says the databases exist (dbInit.enabled: false, dbInit.externallyManaged: true). Every DSN carries ?sslmode=require: the connection is encrypted, and the server certificate is not verified. verify-ca and verify-full are not supported yet, because the chart mounts no server CA. There is no Cloud SQL Auth Proxy.
GCS and service accounts
Section titled “GCS and service accounts”The bucket and the Google service account are yours to create; the chart never creates a GCS bucket (objectInit stays off):
gcloud storage buckets create "gs://$PROJECT_ID-elitea-artifacts" --location "$REGION" \ --uniform-bucket-level-access --public-access-prevention
gcloud iam service-accounts create elitea-main
# Bucket-scoped, never project-wide.gcloud storage buckets add-iam-policy-binding "gs://$PROJECT_ID-elitea-artifacts" \ --member "serviceAccount:elitea-main@$PROJECT_ID.iam.gserviceaccount.com" \ --role roles/storage.admin
# Only the elitea-main KSA may act as it.gcloud iam service-accounts add-iam-policy-binding \ "elitea-main@$PROJECT_ID.iam.gserviceaccount.com" \ --role roles/iam.workloadIdentityUser \ --member "serviceAccount:$PROJECT_ID.svc.id.goog[$NS/elitea-main]"roles/storage.objectAdmin alone is not enough: elitea-main rewrites the bucket’s lifecycle rule at every start (storage.buckets.update) and exits when it cannot, so it crash-loops. roles/storage.admin bound on the bucket covers it; so does objectAdmin plus a custom role with storage.buckets.get and storage.buckets.update.
Optional: Vertex AI without a key. The LLM gateway can call Vertex AI as its own service account for platform credentials (llmGateway.workloadIdentity, on in values-gke.yaml):
gcloud iam service-accounts create elitea-llmgcloud projects add-iam-policy-binding "$PROJECT_ID" \ --member "serviceAccount:elitea-llm@$PROJECT_ID.iam.gserviceaccount.com" \ --role roles/aiplatform.usergcloud iam service-accounts add-iam-policy-binding \ "elitea-llm@$PROJECT_ID.iam.gserviceaccount.com" \ --role roles/iam.workloadIdentityUser \ --member "serviceAccount:$PROJECT_ID.svc.id.goog[$NS/elitea-llm-gateway]"Platform models with a service account covers AI DIAL and the admin console side.
Edge: IP, DNS, TLS and an optional proxy
Section titled “Edge: IP, DNS, TLS and an optional proxy”The chart’s GKE Ingress is one / rule to elitea-main. elitea-main serves the API, redirects / to /app/, relays /app and /docs to elitea-web, and serves the MCP endpoint (/app/<project id>/mcp) itself, so no path rule, regular expression or header filter is needed at the load balancer. elitea-main deletes client-supplied identity headers itself.
gcloud compute addresses create elitea-ip --globalgcloud compute addresses describe elitea-ip --global --format 'value(address)'# Point your DNS record (elitea.example.com) at that address.With main.ingress.gke on, the chart renders, next to the Ingress:
- a BackendConfig:
timeoutSec: 3600, so a streamed answer is not cut at the load balancer’s 30-second default; an HTTP health check on/healthzport 8080; connection draining; and an optional Cloud ArmorsecurityPolicy; - a FrontendConfig: HTTP to HTTPS redirect, and an optional
sslPolicy; - the
cloud.google.com/negannotation on the elitea-main Service, so Google’s front ends reach the pods directly. There are no ingress pods, sonetworkPolicies.main.ingressFrommust admit Google’s ranges35.191.0.0/16and130.211.0.0/22;values-gke.yamldoes.
main.ingress.certManager.clusterIssuer annotates the Ingress, and cert-manager issues the tls[].secretName Secret that the load balancer serves. Server-sent event streams carry a comment after 25 seconds of silence, well inside an idle timeout of about 100 seconds at a proxy in front.
Behind a CDN or proxy (Cloudflare or similar): issue the origin certificate through that provider’s cert-manager issuer (main.ingress.certManager.issuerKind and issuerGroup name an external issuer), and keep everyone else off the load balancer with a Cloud Armor policy that admits only the proxy’s published ranges:
gcloud compute security-policies create elitea-proxy-onlygcloud compute security-policies rules create 1000 \ --security-policy elitea-proxy-only \ --src-ip-ranges "<proxy range>,<proxy range>" --action allowgcloud compute security-policies rules update 2147483647 \ --security-policy elitea-proxy-only --action deny-403A rule takes a limited number of ranges, so split a long list across several rules. Then set main.ingress.gke.backendConfig.securityPolicy: elitea-proxy-only. Add the proxy’s ranges to global.trustedProxyCidrs only if elitea-main should take the client address from the proxy’s forwarding header. That one list fills both main.env.ELITEA_TRUSTED_PROXY_CIDRS and the authentication document’s trusted_proxy_cidrs.
The Service names are fixed (elitea-main, elitea-llm-gateway, and so on), not prefixed with the release name. This is by design: certificate SANs, the gateway’s own origins and the NetworkPolicies name them. Install one release in a namespace.
External Secrets Operator
Section titled “External Secrets Operator”Install the External Secrets Operator (ESO) and give it a ClusterSecretStore on Secret Manager. The example store, bootstrap/cluster-secret-store.yaml, has no auth block: ESO’s controller authenticates as its own Kubernetes service account through Workload Identity. The file’s header has the gcloud commands that create that service account, grant it roles/secretmanager.secretAccessor on the two secrets below only, and link it. Then:
kubectl apply -f bootstrap/cluster-secret-store.yamlkubectl get clustersecretstore gcp-secret-manager # READY TrueWith externalSecrets.enabled, the chart renders one external-secrets.io/v1 ExternalSecret for every Secret it consumes, derived from the same values the workloads read, so the list cannot drift from the pods. They are PreSync hooks in wave -30, so the Secrets exist before the migration Jobs that read them. Plain Kubernetes Secrets (existingSecret and friends) still work, but are not recommended in production.
Argo CD
Section titled “Argo CD”Argo CD pulls the chart from ghcr.io as an OCI Helm chart. Register the registry once; the charts are public, so the entry carries no credential:
kubectl apply -f bootstrap/helm-repository.yamlArgo CD renders the chart with the Helm 3 it bundles, and the chart supports that (Helm 3.8 or newer). The Argo CD project you deploy into must allow the elitea namespace as a destination. The OCI chart carries its dependencies (the upstream NATS, Qdrant, KEDA and prometheus-adapter charts included), so the project needs no other source repository. A git source also needs https://nats-io.github.io/k8s/helm/charts/, https://qdrant.github.io/qdrant-helm, https://kedacore.github.io/charts and https://prometheus-community.github.io/helm-charts in sourceRepos (see GitOps with Argo CD). In an air-gapped cluster, mirror the OCI chart: it carries all four.
Autoscaling
Section titled “Autoscaling”values-gke.yaml turns autoscaling on: the worker scales on its JetStream backlog (1 to 4), DeepWiki on its running invocations (1 to 3, KEDA’s postgresql trigger on the host’s own Cloud SQL Secret), the gateway on CPU (1 to 3) and elitea-main on CPU (2 to 3). The worker and DeepWiki need KEDA, and there are two ways to provide it:
-
KEDA installed once for the cluster (the default here). KEDA is cluster infrastructure: it outlives this release, and deleting the release then never takes the CRDs, or other teams’ ScaledObjects, with it. Install it before the first sync.
values-gke.yamlsetsscaling.keda.clusterInstalled: true, which tells the chart about it, because Argo CD does not pass the cluster’s API versions.Terminal window helm repo add kedacore https://kedacore.github.io/chartshelm install keda kedacore/keda --version 2.21.0 -n keda --create-namespace -
KEDA in this release. Set
keda.enabled: trueandscaling.keda.clusterInstalled: false, only in a cluster with no KEDA: it is one per cluster. Add KEDA’s three pods to the resource budget. On the first sync the ScaledObjects come after the CRDs (each carriesSkipDryRunOnMissingResource=true). -
No KEDA. Set
scaling.keda.clusterInstalled: false. The worker (worker.autoscaling.source: auto) then scales on CPU with a HorizontalPodAutoscaler, and the release notes (helm get notes elitea -n elitea) say so. A component that has no CPU fallback keeps its fixed replica count.
With a Prometheus in the cluster, set scaling.prometheus.serverAddress and both the gateway and DeepWiki move to their Prometheus triggers, on concurrent /llm streams and on running invocations. Scaling has what each component scales on, the disruption budgets and the grace periods.
Resource budget
Section titled “Resource budget”values-gke.yaml is sized for 4 vCPU and 10 GiB of requests with every default on (DeepWiki and Inventory included), at steady state and at the peak of any one rollout. CI sums the rendered requests, adds the largest rollout surge, and fails a change that breaks either.
| State | CPU requests | Memory requests |
|---|---|---|
| Steady state | 3700m | 8768Mi |
| Peak during a rollout (the largest single surge) | 3950m | 9920Mi |
The worker is the largest pod (1.4 vCPU, 3 GiB). It does not surge: values-gke.yaml sets worker.rollingUpdate and main.rollingUpdate to maxSurge: 0, maxUnavailable: 1. The old pod stops before the new one starts. Commands wait in JetStream for the minute the new worker takes to start, and one of the two elitea-main replicas keeps serving. With a surging worker the peak would be 5.1 vCPU / 11.5 GiB. Hook Jobs run for seconds and are not counted. At elitea-main’s autoscaler maximum of 3, the steady state is 4100m / 9472Mi; scaling out under load uses the cluster’s headroom above the reservation. To win back 450m / 1.5 GiB, leave DeepWiki or Inventory off (enabled: false). Qdrant also needs a 50Gi PersistentVolumeClaim (standard-rwo). Every container requests at least 50m CPU, the GKE Autopilot minimum, so Autopilot does not raise a request behind the budget.
Mint the material and put it into Secret Manager
Run
deploy/scripts/mint-secrets.shfrom the chart’s source at your release tag. It needsopensslandpython3withcryptography, no cluster and no network. It writes one flat JSON object: each property is the Kubernetes Secret and the key joined by/(for exampleelitea-main-sso/application-secret-key), plus one property for each value that several Secrets carry (runtime-ca.crt,runtime-ca.key,command-signing-keyring.json,gateway-identity-secret).values-gke.yamlreads those shared properties (externalSecrets.sharedProperties: true).Terminal window PUBLIC_URL=https://elitea.example.com NAMESPACE=elitea \OIDC_CLIENT_SECRET='<client secret from your IdP>' \AGENT_CHECKPOINT_CONNECTION='postgresql://elitea_v2:<password>@<Cloud SQL IP>:5432/elitea_v2_agentstate?sslmode=require' \deploy/scripts/mint-secrets.sh elitea-platform.jsongcloud secrets create elitea-platform --data-file elitea-platform.jsonprintf '%s' 'postgresql://elitea_v2:<password>@<Cloud SQL IP>:5432/elitea_v2?sslmode=require' \| gcloud secrets create elitea-database-url --data-file -rm elitea-platform.jsonThe script prints the Form login it minted, the properties it could not know (the OIDC client secret and the DSNs, when you do not pass them), and the values that go into your values file (
global.publicUrl,main.runtime.signingKeyId, the OIDC issuer and client id). The worker certificate carriesspiffe://<host of PUBLIC_URL>/ns/<NAMESPACE>/sa/agent-worker: the identity the chart derives fromglobal.publicUrlin the same namespace.Two keys the platform reads as raw bytes are minted as printable text, because a Secret Manager property is a string:
auth-attempt-keyis 43 base64 characters (32 to 64 bytes are accepted), andworker-output-spool-keyis 32 alphanumeric characters (exactly 32 bytes; a 44-character base64 string is refused).That is the whole inventory for the install on this page, every default on: 25 properties in
elitea-platform, and the platform DSN alone inelitea-database-url. Turning on an optional component (the cloud executor, say) adds Secrets, and turning a default off (deepwiki.enabled: false) removes its own; render the chart with your values and read the ExternalSecrets for the exact property list:Terminal window helm template elitea oci://ghcr.io/elitea-ng/charts/elitea --version <version> \-f values-gke.yaml -f your-values.yaml | yq 'select(.kind == "ExternalSecret")'A missing property holds back its whole Kubernetes Secret: ESO syncs all keys of an ExternalSecret or none. Keep no copy of the JSON in git; losing
secrets-master-keymakes every stored credential unreadable.Write the values
Start from the chart’s own
values-gke.yamland override only what is yours. The example Application,applications/elitea.yaml, does exactly that:valueFiles: [values-gke.yaml](the file ships inside the published chart, so it always matchestargetRevision) and a shortvaluesblock with the store name,global.publicUrl,global.trustedProxyCidrs, the Google service accounts, the bucket, the OIDC client and the egress allowlist. PintargetRevisionto an exact chart version.global.publicUrlfills each of these that you leave empty: the Ingress host and its TLS host,DEPLOYMENT_URL,OIDC_REDIRECT_URI(<publicUrl>/auth/oidc/callback, whenOIDC_ISSUER_URLis set), the authentication document’spublic_origin, the gateway’sGATEWAY_SELF_LLM_ORIGINS(<publicUrl>/llmandhttp://elitea-main:8080/llm),worker.runtime.publicBaseUrlandworker.runtimeSession.expectedIdentity. Set one only to override it. The chart refuses to render while any value still holds a<...>placeholder, and lists each by its path.To keep your own values file in your GitOps repository, use a multi-source Application: the OCI chart as one source, and your repository as a
ref: valuessource.valueFilesthen namesvalues-gke.yaml(inside the chart, at the pinned version) and$values/<path>/elitea.yaml. Do not copyvalues-gke.yaml: a copy does not follow the chart’s version. GitOps with Argo CD has the manifest.Read
values-gke.yamlonce in full: it states the Cloud SQL, GCS, Ingress, NetworkPolicy, NATS and Qdrant settings this page relies on. It lets DeepWiki and Inventory clone fromgithub.comonly; restatedeepwiki.env.ELITEA_DEEPWIKI_GIT_ALLOWLISTandinventory.env.ELITEA_INVENTORY_GIT_ALLOWLISTfor your git hosts (the chart copies each to elitea-main). For three Qdrant nodes, add the chart’svalues-qdrant-ha.yaml.Terminal window helm pull oci://ghcr.io/elitea-ng/charts/elitea --version <version> --untarless elitea/values-gke.yamlRender your values before git does it for you. Each refusal names the field:
Terminal window helm template elitea ./elitea -n elitea -f elitea/values-gke.yaml \-f your-values.yaml > /dev/null && echo rendersFor three NATS nodes, add the chart’s
values-nats-ha.yamlaftervalues-gke.yaml; it raises the bootstrap’s replica count with it.The release notes (
helm get notes elitea -n elitea, or the output ofhelm install) print thegcloudWorkload Identity bindings for the Google service accounts your values name.Apply the root Application
Copy
deploy/argocd/examples/gke/into your GitOps repository, pointroot.yamlat it, and apply the root once:Terminal window kubectl apply -f root.yamlIt renders
applications/, which holds one Application,elitea.bootstrap/is not rendered by the root: you applied it by hand above.The first sync
Everything happens inside the one
eliteaApplication, in this order:Phase What runs PreSync, wave-30the ExternalSecrets; Argo CD waits until each is Ready PreSyncthe platform and agent-state migrations; a failed migration stops the sync PreSync, wave10the workload-session Job (the worker’s row in elitea_runtime.workload_sessions)wave -15the certManager.issuersCA chain and Issuerswave -10the NATS server, its CA, server and client certificates, and the elitea-nats-bootstrapJob, aSynchook that waits for NATS (up to 300 seconds, then the pod restarts in place,restartPolicy: OnFailure) and creates every stream and KV bucketwave -5elitea-vector and Qdrant (the bundled qdrant chart) with its TLS Certificate wave 0elitea-main, elitea-web, the scheduler, the LLM gateway, DeepWiki, Inventory, the OTel collector, the Ingress, BackendConfig and FrontendConfig wave 1the agent worker The Google load balancer takes several minutes to program after the Ingress appears, and the certificate is issued once DNS points at the static IP.
With the Helm CLI instead of Argo CD, the same chart installs with
helm install elitea oci://ghcr.io/elitea-ng/charts/elitea --version <version> -n elitea -f values-gke.yaml -f your-values.yaml. Do not pass--waiton a first install: the bootstrap Job is a post-install hook, and the workloads wait for the streams it creates. Helm 4.3 or newer is recommended for the CLI; Helm 3.8 or newer also works.Verify
Terminal window kubectl -n elitea get externalsecrets # every one READY Truekubectl -n elitea get certificates # every one READY Truekubectl -n elitea rollout status statefulset/elitea-natsargocd app get elitea # Synced, Healthy; hooks Succeededcurl -si https://elitea.example.com/healthz | grep -i -e '^HTTP' -e '^vary'# HTTP/2 200 and "vary: Origin": elitea-main answered, not the load balancer.curl -si https://elitea.example.com/ | grep -i '^location' # /app/curl -s https://elitea.example.com/app/ | head -c 200 # the web app's HTMLArgo CD deletes the bootstrap Job when it succeeds, so
argocd app getis where its result shows; a Job still in the namespace is one that failed, and its log names the stream or account. Then sign in, add a model provider and send a chat message, as in Deploy from scratch, Step 8.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause | Fix |
|---|---|---|
The Ingress has no address, or the backend is UNHEALTHY | the load balancer’s health checks are refused by the NetworkPolicy | networkPolicies.main.ingressFrom must hold 35.191.0.0/16 and 130.211.0.0/22 |
| Streams stop with a 502 after about 30 seconds | the BackendConfig is not attached, so the backend keeps the 30-second default | check main.ingress.gke.enabled (or className: gce), the cloud.google.com/backend-config annotation on the Service and timeoutSec: 3600 |
| Every request returns 403 from Google | the Cloud Armor policy does not admit the caller | add the proxy’s current ranges, or clear securityPolicy while you test |
| The sync waits on an ExternalSecret that never turns Ready | the store, or ESO’s identity, cannot read the secret, or a property is missing | kubectl -n elitea describe externalsecret <name>; check the store’s READY, ESO’s roles/secretmanager.secretAccessor and its Workload Identity link; compare the properties with the render |
| elitea-main CrashLoopBackOff right after a green sync, naming the bucket | the service account cannot update the bucket’s lifecycle rule | grant roles/storage.admin on the bucket (not only objectAdmin) |
| elitea-main reads the bucket as the node’s identity, or is refused | the iam.gke.io/gcp-service-account annotation or the workloadIdentityUser binding is missing | set both for $NS/elitea-main |
Migrations fail with an SSL or pg_hba error | the DSN has no sslmode, or verify-ca / verify-full | use sslmode=require in both DSNs |
Migrations fail with permission denied to create database or a missing database | the databases were not prepared by hand | run the three SQL statements of Cloud SQL |
FATAL: sorry, too many clients already | postgresql.maxConnections is above the instance’s flag | set both to the same number; lower main.autoscaling.maxReplicas |
| The bootstrap Job or the NATS pod stays Pending on a Secret | cert-manager has not issued the NATS certificates | kubectl -n elitea get certificates,certificaterequests; cert-manager must be running before the sync |
| elitea-vector waits for its certificate | the elitea-runtime-ca Issuer is not Ready | its Secret comes from the elitea-runtime-ca ExternalSecret (runtime-ca.crt, runtime-ca.key); check that one first |
A Certificate names an Issuer that does not exist (issuer.cert-manager.io "elitea-internal-ca" not found) | certManager.issuers.create is off and the Issuers were not made by hand, or a kind does not match what exists | set create: true, or make the Issuers and set certManager.issuers.*.kind |
The render fails with these values still hold a <...> placeholder | a value still holds a <...> from values-gke.yaml | replace each path the message lists |
| elitea-main refuses the worker’s runtime session | the worker certificate’s URI SAN differs from worker.runtimeSession.expectedIdentity | mint the certificate for spiffe://<host>/ns/<namespace>/sa/agent-worker, or set expectedIdentity to the SAN it has |
| No trace reaches a collector | otelCollector.enabled is off and no endpoint is set, so export is off (OTEL_SDK_DISABLED=true) | set global.otelEndpoint, or turn the collector on |
| The public certificate is never issued | DNS does not point at the static IP yet, or the issuer named in certManager does not exist | kubectl -n elitea describe certificate elitea-tls |
| elitea-main exits naming NATS or the presence bucket | the bootstrap Job has not succeeded | argocd app get elitea, then the Job’s log |
| Vertex AI calls fail with a permission error | the gateway’s service account lacks roles/aiplatform.user, or llmGateway.workloadIdentity is off | grant the role; keep the annotation and the switch |
Deploy from scratch has the symptoms that are not specific to Google Cloud. GitOps with ArgoCD covers promotion and rollback.