GitOps with ArgoCD
deploy/argocd/ is the GitOps composition for the Kubernetes path: one root Application that renders every child Application under deploy/argocd/applications/. There is one child, elitea, because there is one chart: the platform, NATS JetStream with its stream bootstrap, and the vector store are all in the elitea chart. There is no umbrella chart; an earlier one (deploy/helm/elitea-platform/) was scaffolded and never built, and was removed once app-of-apps replaced it.
Apply the root once
Section titled “Apply the root once”kubectl apply -f deploy/argocd/app-of-apps.yamlThis is the only object you apply by hand. kubectl apply -f deploy/argocd/ (no -R) reaches only this file — the children come from ArgoCD reading the directory, not from a second kubectl apply. The root itself is deliberately not one of the children it manages: an Application that enumerated itself could prune itself.
flowchart LR
Root["elitea-platform (root)"] -->|renders| Dir["deploy/argocd/applications/*.yaml"]
Dir --> Elitea["elitea: one chart, one release"]
Elitea --> Hooks["PreSync: ExternalSecrets, migrations, workload session"]
Hooks --> Issuers["wave -15: certManager.issuers, when create"]
Issuers --> Nats["wave -10: NATS, its certificates, the bootstrap Job"]
Nats --> Vector["wave -5: elitea-vector, Qdrant"]
Vector --> Platform["wave 0: the platform"]
Platform --> Worker["wave 1: the worker"]Sync order inside the one Application
Section titled “Sync order inside the one Application”| Phase | Objects | Why this order |
|---|---|---|
PreSync | the ExternalSecrets, when externalSecrets.enabled (wave -30); the database preparation, the platform and agent-state migrations; the workload-session Job (wave 10) | The Secrets exist before the Jobs that read them. A failed migration aborts the sync, and the previous pods keep serving. |
wave -15 | with certManager.issuers.create: true: the internal CA chain (a self-signed Issuer, a CA Certificate, the CA Issuer) and the runtime CA Issuer | Every mTLS Certificate of the later waves names one of them. |
wave -10 | the NATS StatefulSet and Services, its CA Issuer, server and route certificates, NetworkPolicy, and the elitea-nats-bootstrap Job, a Sync hook | The streams, KV buckets and the scheduler’s consumer exist before anything that dials them. The Job is a Sync hook, not PostSync: PostSync would wait for workloads that wait for the streams it creates. It waits for NATS itself (up to NATS_CONNECT_WAIT, 300 seconds), and its pod restarts in place (restartPolicy: OnFailure) until the Job’s backoffLimit, so a slow NATS start does not fail the sync. It is idempotent, and Argo CD deletes it once it succeeds. |
wave -5 | elitea-vector and Qdrant | The indexing runtime’s store, ready before the worker. |
wave 0 | elitea-main, elitea-web, the scheduler, the LLM gateway, the OTel collector, the edge objects | The platform. |
wave 1 | the agent worker | After everything it calls. |
cert-manager is a prerequisite: the NATS CA and every NATS identity are cert-manager Certificates, issued from the chart’s own namespaced elitea-nats-ca Issuer. That Issuer is why NATS lives in the platform’s namespace. To use a NATS you run yourself, set nats.enabled: false and point natsClient.* at it; the bootstrap Job still runs against it unless natsBootstrap.enabled: false.
Where each value comes from
Section titled “Where each value comes from”deploy/argocd/applications/elitea.yaml states every value in one spec.source.helm block — nothing reaches the release from anywhere else:
Chart defaults
valueFiles: [values.yaml], read from the chart’s own repository path.Stated parameters
postgresql.existingSecretandpostgresql.key— the one database Secret every component derives itsDATABASE_URLfrom. NATS needs no parameter: the chart runs it as Serviceelitea-natsin the Application’s namespace and issues each client its certificate.The empty parameters you must fill in
llmGateway.env.GATEWAY_SELF_LLM_ORIGINSandllmGateway.egressPostureship empty in git on purpose — neither can get a safe chart default — and so does the namespace of whatever forwards outside traffic to elitea-main (networkPolicies.main.ingressFrom). Left empty, the chart refuses to render and the Application reportsSyncFailednaming the field, rather than installing a gateway with a disarmed guard. Settingglobal.publicUrlfillsGATEWAY_SELF_LLM_ORIGINS(see Configuration).
Fill the empty parameters in your own GitOps copy of the file, or imperatively:
argocd app set elitea \ -p llmGateway.env.GATEWAY_SELF_LLM_ORIGINS=https://elitea.example.com/llm/v1 \ -p llmGateway.egressPosture=public-unrestrictedOverriding values per environment
Section titled “Overriding values per environment”To point an environment at a different capability set — values-standalone.yaml, values-gke.yaml, values-nats-ha.yaml for three NATS nodes, or your own file — add it to spec.source.helm.valueFiles in that environment’s copy of elitea.yaml, after values.yaml (Helm merges files in list order, so later files win):
spec: source: helm: valueFiles: - values.yaml - values-standalone.yaml parameters: - name: platform.aiProjectId value: "1"Keep one Application manifest per environment (a staging/elitea.yaml, a prod/elitea.yaml, or separate branches/paths per ArgoCD Application’s targetRevision) rather than templating the Application itself — the file is already the full statement of what an environment installs.
Source the published chart
Section titled “Source the published chart”applications/elitea.yaml syncs the chart from this repository’s main, which suits a development cluster. A production environment should pin a release of the published chart instead:
spec: source: repoURL: ghcr.io/elitea-ng/charts # registered in Argo CD with enableOCI chart: elitea targetRevision: "1.2.3" # an exact release; there is no latest helm: valueFiles: - values-gke.yaml # value files ship inside the chartThe published chart carries its dependencies (the upstream NATS, Qdrant, KEDA and prometheus-adapter charts and the sandbox-runtime installer), so an OCI source needs nothing else and is the recommended source. A git source (path: deploy/helm/elitea) has no charts/ (it is never committed): Argo CD runs helm dependency build itself from the committed Chart.lock. Every chart repository must be reachable from the repo-server and, in an AppProject that restricts sourceRepos, listed there. The list includes KEDA’s and prometheus-adapter’s repositories even while keda.enabled and prometheusAdapter.enabled are false, because helm dependency build fetches every dependency:
spec: sourceRepos: - https://github.com/<your-organisation>/<your-fork> # or this repository - https://nats-io.github.io/k8s/helm/charts/ # the nats dependency - https://qdrant.github.io/qdrant-helm # the qdrant dependency - https://kedacore.github.io/charts # the keda dependency - https://prometheus-community.github.io/helm-charts # the prometheus-adapter dependencyIn an air-gapped cluster, mirror the published OCI chart instead: it already carries all four, so nothing else needs mirroring.
deploy/argocd/examples/gke/ has the full Application, the repository registration and an example ClusterSecretStore.
Layer values from your repository
Section titled “Layer values from your repository”values-gke.yaml ships inside the published chart, so valueFiles: [values-gke.yaml] always reads the copy of the version you pin. To keep your own values in your GitOps repository too, use a multi-source Application: the chart is one source, your repository a second source with ref: values, and $values/... names your file. Do not copy values-gke.yaml into your repository: a copy does not move with targetRevision.
spec: sources: - repoURL: ghcr.io/elitea-ng/charts chart: elitea targetRevision: "1.2.3" helm: releaseName: elitea valueFiles: - values-gke.yaml # inside the chart, at 1.2.3 - $values/elitea/values/elitea.yaml # yours; later files win - repoURL: https://git.example.com/your-org/elitea-gitops.git targetRevision: main ref: valuesA chart from this git repository (path: deploy/helm/elitea) at a release tag works the same way: name values-gke.yaml relative to the chart path, and keep targetRevision on the tag. The chart refuses to render while your values leave a <...> placeholder of values-gke.yaml, and the Application reports SyncFailed with every path.
Image tag promotion
Section titled “Image tag promotion”Every first-party image and every chart is stamped with the same release version by .github/workflows/publish.yml (a stable release you dispatch from main, or a -nightly./-dev. pre-release, which never moves latest) — one tag for the whole platform, because a per-component tag could only ever express a mistake. Promoting a release is changing the chart version an Application pulls:
argocd app set elitea --revision <new-version> # an OCI chart source # or, for a git source, edit spec.source.targetRevision to the release tagThe published chart’s own image.tag already defaults to the chart’s version — only override image.tag to pin an environment behind the chart’s own release, or to test a release candidate. Read the entries in docs/UPGRADING.md between the two versions first.
Rollback
Section titled “Rollback”ArgoCD’s own history covers a sync gone wrong:
argocd app rollback elitea <history-id>For a release-level rollback (a bad image), point the Application back at a previous chart version and let selfHeal: true reconcile it — the chart’s own migration hook still runs on the way there, so a rollback that also needs a schema rollback is a database operation, not something this reconciliation does for you. If a CI release itself failed mid-publish, the rollback job in publish.yml already deleted the charts and images it had pushed, so there is nothing half-published to roll back to.
Your own GitOps repository
Section titled “Your own GitOps repository”The deploy/argocd/ and deploy/helm/ in this repository are the template. A production deployment rolls from a GitOps repository your organisation owns, which layers its own values on top of the published chart and gets its secrets from a secret manager — nothing in this repository points at any particular environment. Copy deploy/argocd/examples/gke/ (or the structure of deploy/argocd/) into that repository rather than syncing this one.