Skip to main content

GitOps Workflow β€” Service Onboarding & Promotion

This page describes the enterprise-grade GitOps strategy in use on the minicloud platform. It combines two complementary patterns:

  • Combo 1 β€” ArgoCD + Kustomize for internal apps (own source code, per-environment image promotion)
  • Combo 2 β€” ArgoCD + Helm for third-party tools (upstream charts, values files in minicloud-ansible)

Why Two Combos?​

ConcernInternal ServicesThird-Party Charts
Source of truthminicloud-gitops/services/<name>/minicloud-gitops/helm-values/<name>-values.yaml
Diff toolKustomize overlaysHelm values
Env promotionImage tag bump per overlaySingle values file, chart version pin
CI integrationkustomize edit set image in dev overlayEdit values β†’ commit β†’ ArgoCD auto-syncs
Blast radiusDev auto-syncs; staging/prod require PRHelm controls rollout

Combo 1 β€” Kustomize base + overlays​

Directory layout​

minicloud-gitops/services/<service-name>/
β”œβ”€β”€ base/
β”‚ β”œβ”€β”€ kustomization.yaml ← lists resources, NO image tag
β”‚ β”œβ”€β”€ deployment.yaml ← no namespace field
β”‚ └── service.yaml
└── overlays/
β”œβ”€β”€ dev/
β”‚ β”œβ”€β”€ kustomization.yaml ← namespace + newTag (CI updates this)
β”‚ └── patch-resources.yaml
β”œβ”€β”€ staging/
β”‚ β”œβ”€β”€ kustomization.yaml ← namespace + newTag (PR to promote)
β”‚ └── patch-resources.yaml
└── prod/
β”œβ”€β”€ kustomization.yaml ← namespace + newTag (PR to promote)
β”œβ”€β”€ patch-resources.yaml
β”œβ”€β”€ ingress.yaml ← prod-only
└── certificate.yaml ← prod-only

Key invariant: the base has no images: block and no namespace: field. Each overlay is fully self-contained. Ingress and TLS certificates only exist in the prod overlay β€” dev and staging have no public exposure.

How promotion works​

CI push to main
β”‚
β”œβ”€ build + push image to ghcr.io + Harbor
β”‚
└─ bump overlays/dev/kustomization.yaml ← kustomize edit set image
β”‚
└─ ArgoCD auto-syncs platform-demo-dev (2/2 Running in platform-demo-dev ns)
β”‚
β”œβ”€ open PR: bump overlays/staging/kustomization.yaml
β”‚ └─ ArgoCD manual sync β†’ platform-demo-staging
β”‚
└─ open PR: bump overlays/prod/kustomization.yaml
└─ ArgoCD manual sync β†’ platform-demo (gitops-demo ns)

No single CI push ever reaches staging or prod. Both require an explicit pull request and a human clicking Sync in ArgoCD.

ArgoCD Applications​

Three ArgoCD Applications manage the three overlays:

AppPathNamespaceSync
platform-demo-devservices/platform-demo/overlays/devplatform-demo-devAutomated
platform-demo-stagingservices/platform-demo/overlays/stagingplatform-demo-stagingManual
platform-demoservices/platform-demo/overlays/prodgitops-demoManual

All three are in the minicloud-platform AppProject β€” no wildcard destinations.


Combo 2 β€” Helm + ArgoCD (third-party charts)​

Third-party Helm charts are deployed via ArgoCD multi-source apps. Both the upstream chart and the values file are in version-controlled, auditable locations.

sources:
- repoURL: https://helm.releases.hashicorp.com
chart: vault
targetRevision: "0.33.0"
helm:
valueFiles:
- $values/helm-values/vault-values.yaml
- repoURL: https://github.com/andrelair-platform/minicloud-gitops.git
targetRevision: main
ref: values

Values files live in minicloud-gitops/helm-values/ β€” the same repo that manages all deployment config. Edit a values file, commit, and ArgoCD syncs automatically. Version pinning is done via targetRevision in the ArgoCD Application.

Repository role separation (the rule)​

minicloud-ansible = cluster bootstrap only (MAAS, k3s install, OS config)
minicloud-gitops = ALL desired application state (manifests, values, overlays)
service repos = code only (Containerfile, source, CI workflow)

When you need to change a Helm value for Vault: edit minicloud-gitops/helm-values/vault-values.yaml, commit, push. ArgoCD picks up the change within 3 minutes. You never touch minicloud-ansible.

All platform tools are ArgoCD-managed​

Every Helm release on the platform is managed by an ArgoCD Application that uses the multi-source pattern: upstream chart + values file from minicloud-gitops/helm-values/.

ToolArgoCD AppValues file
ArgoCD itselfargo-cdargocd-values.yaml
Authentikauthentikauthentik-values.yaml
Backstagebackstagebackstage-values.yaml
cert-managercert-managercert-manager-values.yaml
Chaos Meshchaos-meshchaos-mesh-values.yaml
Falcofalcofalco-values.yaml
Gatekeepergatekeepergatekeeper-values.yaml
Harborharborharbor-values.yaml
KEDAkedakeda-values.yaml
kube-prometheus-stackkube-prometheus-stackkube-prometheus-stack-values.yaml
Langfuselangfuselangfuse-values.yaml
Lokilokiloki-values.yaml
NATSnatsnats-values.yaml
Nextcloudnextcloudnextcloud-values.yaml
NFS provisionernfs-provisionernfs-provisioner-values.yaml
NGINX Ingressnginx-ingressnginx-ingress-values.yaml
Ollama (primary)ollamaollama-values.yaml
Ollama (secondary)ollama-secondaryollama-secondary-values.yaml
Ollama (tertiary)ollama-tertiaryollama-tertiary-values.yaml
Open WebUIopen-webuiopen-webui-values.yaml
Podinfopodinfopodinfo-values.yaml
Polarispolarispolaris-values.yaml
PostgreSQL AIpostgresql-aipostgresql-ai-values.yaml
Promtailpromtailpromtail-values.yaml
Vaultvaultvault-values.yaml
Velerovelerovelero-values.yaml

To change any configuration: edit the values file in minicloud-gitops/helm-values/, commit, push. ArgoCD picks it up within 3 minutes. No helm upgrade commands, no SSH to controller.


The base neutrality rule​

The base/ directory must be 100% environment-neutral. It must never contain:

Forbidden in baseWhyWhere it belongs
namespace: fieldNamespace is environment-specificoverlays/<env>/kustomization.yaml namespace field
ingress.yaml / certificate.yamlOnly prod gets a public URLoverlays/prod/ only
Environment-specific resource limitsdev and prod have different sizingoverlays/<env>/patch-resources.yaml
Moving image tagCI only updates devoverlays/dev/kustomization.yaml images block
Environment variables with env-specific valuesDifferent DBs, URLs per envoverlays/<env>/patch-env.yaml

The test: run kustomize build services/<service>/base β€” the output should deploy identically to any namespace without modification. If it references a specific hostname, secret name, or resource size, it belongs in an overlay patch.

Why this matters: if the base has environment-specific config, all overlays inherit it. A prod URL leaking into dev, a prod DB URL in staging, or production resource limits running in dev all stem from the same mistake: config that belongs in an overlay ended up in base.


Adding a new internal service​

Use the template scaffolding in services/_template/:

cd minicloud-gitops
cp -r services/_template services/<your-service>

# Replace all placeholders
find services/<your-service> -type f -exec sed -i 's/SERVICE_NAME/<your-service>/g' {} +

# Set the initial image tag in each overlay
vim services/<your-service>/overlays/dev/kustomization.yaml
vim services/<your-service>/overlays/staging/kustomization.yaml
vim services/<your-service>/overlays/prod/kustomization.yaml

Then:

  1. Add the namespace to AppProject (manifests/argocd-project/00-project.yaml) before creating any ArgoCD Application β€” permission violations happen otherwise.
  2. Add ArgoCD Application files in apps/ for dev, staging, and prod.
  3. Update Vault Kubernetes auth role if the service uses vault-agent injection β€” add the new namespaces to bound_service_account_namespaces.
  4. Add Ingress + Certificate to overlays/prod/ if the service needs a public URL.
  5. Wire CI in the service repo: set GITOPS_OVERLAY: services/<your-service>/overlays/dev and use kustomize edit set image.

CI workflow (platform-demo as reference)​

The bump-gitops job in .github/workflows/ci.yml:

- name: Install kustomize
run: |
curl -sSL https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh | bash
sudo mv kustomize /usr/local/bin/

- name: Bump image tag in dev overlay and push signed commit
run: |
cd "${{ env.GITOPS_OVERLAY }}"
kustomize edit set image \
harbor.10.0.0.200.nip.io/ghcr/${{ github.repository }}:${{ needs.build-and-push.outputs.image_tag }}
cd -
git add "${{ env.GITOPS_OVERLAY }}/kustomization.yaml"
git commit -S -m "ci(<service>): bump dev image to ${{ needs.build-and-push.outputs.image_tag }}"
git push

kustomize edit set image updates the images[].newTag in overlays/dev/kustomization.yaml atomically. No fragile yq path expressions, no risk of touching unrelated YAML documents.


Vault auth for new service namespaces​

When a service uses vault-agent injection, update the Kubernetes auth role to cover all three environment namespaces:

VTOKEN=$(cat ~/.vault-root-token)
kubectl exec -n vault vault-0 -- env VAULT_TOKEN=$VTOKEN vault write auth/kubernetes/role/<service> \
bound_service_account_names=<service> \
bound_service_account_namespaces=<service>-dev,<service>-staging,<prod-namespace> \
policies=<service> \
ttl=1h

If the pod stays in Init:0/1, delete it to force a retry β€” the Vault auth cache doesn't auto-refresh.


Operational reference​

TaskCommand
Force ArgoCD re-pollkubectl annotate app -n argocd <app> argocd.argoproj.io/refresh=normal
Manually sync stagingkubectl patch application platform-demo-staging -n argocd --type merge -p '{"operation":{"initiatedBy":{"username":"kubectl"},"sync":{"revision":"HEAD","syncOptions":["ServerSideApply=true","CreateNamespace=true"]}}}'
Manually sync prodSame pattern with platform-demo
Verify kustomize outputkustomize build services/platform-demo/overlays/dev
Check what ArgoCD would changeArgoCD UI β†’ App Diff
Roll back devBump newTag in overlays/dev back to prior SHA, push

Known Gotchas & Fixes​

ArgoCD "resource belongs to multiple apps" conflict​

Symptom: An app stays OutOfSync with the condition Secret/X is part of applications argocd/app-a and app-b.

Root cause: Two ArgoCD Applications both track the same cluster resource. Common case: a Helm chart natively renders a Secret, and an ESO ExternalSecret creates a secret with the same name (creationPolicy: Owner adds an ownerReference but the Helm labels remain, so ArgoCD sees dual ownership).

Fix: Identify which app is the authoritative owner of the resource. If the Helm chart creates the resource natively, remove the ESO ExternalSecret so Helm solely owns it. If ESO is authoritative, configure the Helm chart to reference an existing secret (existingSecret: <name>) so the Helm chart no longer renders it.

Example fixed (nextcloud-db, 2026-07-07):

  • The Nextcloud Helm chart's postgresql subchart renders Secret/nextcloud-db natively
  • manifests/eso-platform-secrets/05-nextcloud-db.yaml was creating the same secret from a Vault placeholder (platform/nextcloud-db: db-password=changeme)
  • Fix: deleted 05-nextcloud-db.yaml; ESO pruned the ExternalSecret; Kubernetes GC deleted the Secret (ownerReference cascade); Helm's selfHeal re-created it as the sole owner β†’ nextcloud app Synced

Staging/prod namespace created on first sync​

When you add a new overlay that targets a namespace that doesn't exist yet, ArgoCD shows OutOfSync + Missing. This is expected. Trigger the first sync via:

kubectl patch application <app-name> -n argocd --type merge -p \
'{"operation":{"initiatedBy":{"username":"kubectl"},"sync":{"revision":"HEAD","syncOptions":["ServerSideApply=true","CreateNamespace=true","ServerSideDiff=true"]}}}'

CreateNamespace=true in syncOptions ensures the namespace is created automatically.

Vault Init:0/1 after new namespace added​

When you extend a Vault Kubernetes auth role to a new namespace, existing pods in that namespace don't automatically retry. Delete the stuck pod to force a fresh Vault auth attempt:

kubectl delete pod -n <new-namespace> -l app=<service> --force --grace-period=0