Skip to content

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"| Main

The chart creates none of these. Each one fails the first sync, or a pod soon after it, when it is missing.

PreconditionWhat it must beCommands
GKE clusterDataplane V2, Workload Identity Federation, private nodes, Cloud NATThe cluster
cert-managerinstalled; the chart makes the elitea-internal-ca and elitea-runtime-ca Issuers (certManager.issuers.create: true in values-gke.yaml)cert-manager
Cloud SQLPostgreSQL 16+, private IP, “Allow only SSL connections”, role and two databases created by handCloud SQL
GCS bucket and service accountsa dedicated bucket, the elitea-main service account with roles/storage.admin on that bucket onlyGCS and service accounts
DNS and TLSa global static IP, a DNS record for it, a cert-manager issuer for the public certificateEdge
External Secrets Operatorinstalled, with a ClusterSecretStore on Secret ManagerSecrets
Argo CDinstalled, with the chart registry registeredArgo CD
KEDAinstalled once for the cluster (or keda.enabled: true in a cluster that has none). Without it the worker scales on CPUAutoscaling
Capacityabout 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:

Terminal window
PROJECT_ID=<your project>
REGION=<region> # for example europe-west1
NS=elitea

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:

Terminal window
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-ranges

The cluster on the Google Cloud page has the node sizing and the NodeLocal DNSCache caveat.

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 CA Certificate (ECDSA P-256, five years) and the elitea-internal-ca Issuer over it; nothing leaves the cluster. It issues the gateway, DeepWiki and Inventory hops.
  • elitea-runtime-ca: a CA Issuer over the existing Secret elitea-runtime-ca (certManager.issuers.runtime.caSecretName). It issues elitea-vector’s pair, Qdrant’s elitea-qdrant-tls and the elitea-deepwiki-callback-client / elitea-inventory-callback-client certificates. The chart never generates this CA: the worker certificate and the runtime listeners in Secret Manager are signed by it. With create: true the chart lists the Secret as an ExternalSecret (runtime-ca.crt and runtime-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.

Create a PostgreSQL 16 or newer instance with a private IP on the cluster’s VPC (private services access) and “Allow only SSL connections”:

Terminal window
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-16384

values-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:

Terminal window
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.

The bucket and the Google service account are yours to create; the chart never creates a GCS bucket (objectInit stays off):

Terminal window
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):

Terminal window
gcloud iam service-accounts create elitea-llm
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member "serviceAccount:elitea-llm@$PROJECT_ID.iam.gserviceaccount.com" \
--role roles/aiplatform.user
gcloud 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.

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.

Terminal window
gcloud compute addresses create elitea-ip --global
gcloud 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 /healthz port 8080; connection draining; and an optional Cloud Armor securityPolicy;
  • a FrontendConfig: HTTP to HTTPS redirect, and an optional sslPolicy;
  • the cloud.google.com/neg annotation on the elitea-main Service, so Google’s front ends reach the pods directly. There are no ingress pods, so networkPolicies.main.ingressFrom must admit Google’s ranges 35.191.0.0/16 and 130.211.0.0/22; values-gke.yaml does.

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:

Terminal window
gcloud compute security-policies create elitea-proxy-only
gcloud compute security-policies rules create 1000 \
--security-policy elitea-proxy-only \
--src-ip-ranges "<proxy range>,<proxy range>" --action allow
gcloud compute security-policies rules update 2147483647 \
--security-policy elitea-proxy-only --action deny-403

A 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.

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:

Terminal window
kubectl apply -f bootstrap/cluster-secret-store.yaml
kubectl get clustersecretstore gcp-secret-manager # READY True

With 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 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:

Terminal window
kubectl apply -f bootstrap/helm-repository.yaml

Argo 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.

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.yaml sets scaling.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/charts
    helm install keda kedacore/keda --version 2.21.0 -n keda --create-namespace
  • KEDA in this release. Set keda.enabled: true and scaling.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 carries SkipDryRunOnMissingResource=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.

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.

StateCPU requestsMemory requests
Steady state3700m8768Mi
Peak during a rollout (the largest single surge)3950m9920Mi

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.

  1. Mint the material and put it into Secret Manager

    Run deploy/scripts/mint-secrets.sh from the chart’s source at your release tag. It needs openssl and python3 with cryptography, no cluster and no network. It writes one flat JSON object: each property is the Kubernetes Secret and the key joined by / (for example elitea-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.yaml reads 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.json
    gcloud secrets create elitea-platform --data-file elitea-platform.json
    printf '%s' 'postgresql://elitea_v2:<password>@<Cloud SQL IP>:5432/elitea_v2?sslmode=require' \
    | gcloud secrets create elitea-database-url --data-file -
    rm elitea-platform.json

    The 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 carries spiffe://<host of PUBLIC_URL>/ns/<NAMESPACE>/sa/agent-worker: the identity the chart derives from global.publicUrl in 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-key is 43 base64 characters (32 to 64 bytes are accepted), and worker-output-spool-key is 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 in elitea-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-key makes every stored credential unreadable.

  2. Write the values

    Start from the chart’s own values-gke.yaml and 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 matches targetRevision) and a short values block with the store name, global.publicUrl, global.trustedProxyCidrs, the Google service accounts, the bucket, the OIDC client and the egress allowlist. Pin targetRevision to an exact chart version.

    global.publicUrl fills each of these that you leave empty: the Ingress host and its TLS host, DEPLOYMENT_URL, OIDC_REDIRECT_URI (<publicUrl>/auth/oidc/callback, when OIDC_ISSUER_URL is set), the authentication document’s public_origin, the gateway’s GATEWAY_SELF_LLM_ORIGINS (<publicUrl>/llm and http://elitea-main:8080/llm), worker.runtime.publicBaseUrl and worker.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: values source. valueFiles then names values-gke.yaml (inside the chart, at the pinned version) and $values/<path>/elitea.yaml. Do not copy values-gke.yaml: a copy does not follow the chart’s version. GitOps with Argo CD has the manifest.

    Read values-gke.yaml once in full: it states the Cloud SQL, GCS, Ingress, NetworkPolicy, NATS and Qdrant settings this page relies on. It lets DeepWiki and Inventory clone from github.com only; restate deepwiki.env.ELITEA_DEEPWIKI_GIT_ALLOWLIST and inventory.env.ELITEA_INVENTORY_GIT_ALLOWLIST for your git hosts (the chart copies each to elitea-main). For three Qdrant nodes, add the chart’s values-qdrant-ha.yaml.

    Terminal window
    helm pull oci://ghcr.io/elitea-ng/charts/elitea --version <version> --untar
    less elitea/values-gke.yaml

    Render 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 renders

    For three NATS nodes, add the chart’s values-nats-ha.yaml after values-gke.yaml; it raises the bootstrap’s replica count with it.

    The release notes (helm get notes elitea -n elitea, or the output of helm install) print the gcloud Workload Identity bindings for the Google service accounts your values name.

  3. Apply the root Application

    Copy deploy/argocd/examples/gke/ into your GitOps repository, point root.yaml at it, and apply the root once:

    Terminal window
    kubectl apply -f root.yaml

    It renders applications/, which holds one Application, elitea. bootstrap/ is not rendered by the root: you applied it by hand above.

  4. The first sync

    Everything happens inside the one elitea Application, in this order:

    PhaseWhat 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, wave 10the workload-session Job (the worker’s row in elitea_runtime.workload_sessions)
    wave -15the certManager.issuers CA chain and Issuers
    wave -10the NATS server, its CA, server and client certificates, and the elitea-nats-bootstrap Job, a Sync hook that waits for NATS (up to 300 seconds, then the pod restarts in place, restartPolicy: OnFailure) and creates every stream and KV bucket
    wave -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 --wait on 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.

  5. Verify

    Terminal window
    kubectl -n elitea get externalsecrets # every one READY True
    kubectl -n elitea get certificates # every one READY True
    kubectl -n elitea rollout status statefulset/elitea-nats
    argocd app get elitea # Synced, Healthy; hooks Succeeded
    curl -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 HTML

    Argo CD deletes the bootstrap Job when it succeeds, so argocd app get is 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.

SymptomLikely causeFix
The Ingress has no address, or the backend is UNHEALTHYthe load balancer’s health checks are refused by the NetworkPolicynetworkPolicies.main.ingressFrom must hold 35.191.0.0/16 and 130.211.0.0/22
Streams stop with a 502 after about 30 secondsthe BackendConfig is not attached, so the backend keeps the 30-second defaultcheck 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 Googlethe Cloud Armor policy does not admit the calleradd the proxy’s current ranges, or clear securityPolicy while you test
The sync waits on an ExternalSecret that never turns Readythe store, or ESO’s identity, cannot read the secret, or a property is missingkubectl -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 bucketthe service account cannot update the bucket’s lifecycle rulegrant roles/storage.admin on the bucket (not only objectAdmin)
elitea-main reads the bucket as the node’s identity, or is refusedthe iam.gke.io/gcp-service-account annotation or the workloadIdentityUser binding is missingset both for $NS/elitea-main
Migrations fail with an SSL or pg_hba errorthe DSN has no sslmode, or verify-ca / verify-fulluse sslmode=require in both DSNs
Migrations fail with permission denied to create database or a missing databasethe databases were not prepared by handrun the three SQL statements of Cloud SQL
FATAL: sorry, too many clients alreadypostgresql.maxConnections is above the instance’s flagset both to the same number; lower main.autoscaling.maxReplicas
The bootstrap Job or the NATS pod stays Pending on a Secretcert-manager has not issued the NATS certificateskubectl -n elitea get certificates,certificaterequests; cert-manager must be running before the sync
elitea-vector waits for its certificatethe elitea-runtime-ca Issuer is not Readyits 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 existsset create: true, or make the Issuers and set certManager.issuers.*.kind
The render fails with these values still hold a <...> placeholdera value still holds a <...> from values-gke.yamlreplace each path the message lists
elitea-main refuses the worker’s runtime sessionthe worker certificate’s URI SAN differs from worker.runtimeSession.expectedIdentitymint the certificate for spiffe://<host>/ns/<namespace>/sa/agent-worker, or set expectedIdentity to the SAN it has
No trace reaches a collectorotelCollector.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 issuedDNS does not point at the static IP yet, or the issuer named in certManager does not existkubectl -n elitea describe certificate elitea-tls
elitea-main exits naming NATS or the presence bucketthe bootstrap Job has not succeededargocd app get elitea, then the Job’s log
Vertex AI calls fail with a permission errorthe gateway’s service account lacks roles/aiplatform.user, or llmGateway.workloadIdentity is offgrant 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.