Deploy with Kustomize and GitOps
Keep shared deployment configuration in a base and environment-specific chart values in
overlays. The supplied example uses Kustomize's Helm chart inflation. Argo CD and Flux need
different execution paths; do not point Flux's Kustomize controller at helmCharts and expect
it to run Helm.
Choose the controller path
| Path | How the chart is rendered |
|---|---|
| Local Kustomize | kustomize build --enable-helm, with Helm available. |
| Argo CD | Enable Helm inflation in the repo-server's Kustomize build options, then use the overlay Application. |
| Flux | Reconcile an OCIRepository and HelmRelease through Flux's source/Helm controllers. Use Kustomize to compose those resources. |
Before deploying, complete the Helm prerequisites, image access, namespace storage and TLS setup. Select an exact published chart version and matching values; the example pin is an example, not a promise that it is the latest release.
Adapt the base and overlay
Download the complete example. It includes a namespace base, production overlay, values file and an Azure Blob CSI static volume example:
base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yamlbase/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: stackweaveroverlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
- pv-pvc.yaml
namespace: stackweaver
helmCharts:
- name: stackweaver
repo: oci://ghcr.io/vhco-pro/charts
version: 0.13.4
releaseName: stackweaver
namespace: stackweaver
valuesFile: values.yaml
overlays/production/pv-pvc.yaml
# Static provisioning for ReadWriteMany volumes using Azure Blob CSI driver.
# Uses managed identity of the cluster for authentication - no explicit clientID needed (driver >= v1.24).
# https://github.com/kubernetes-sigs/blob-csi-driver/tree/master/deploy/example/blobfuse-mi
#
# Replace the following placeholders before applying:
# <STORAGE_ACCOUNT> - Azure storage account name (e.g. mystorageaccount)
# <RESOURCE_GROUP> - Resource group containing the storage account (e.g. my-cluster-rg)
# <RUNNER_WORKSPACES_CONTAINER> - Blob container name for runner workspaces (e.g. stackweaver-runner-workspaces)
#
# For other storage providers (AWS EFS, NFS, etc.) replace the csi block with the appropriate
# driver configuration. The PVC name (runner-workspaces-pvc) must stay the same: values.yaml
# names it in runnerWorkspaces.existingClaim.
# ── Runner Workspaces ─────────────────────────────────────────────────────────
apiVersion: v1
kind: PersistentVolume
metadata:
name: runner-workspaces-pv
spec:
accessModes:
- ReadWriteMany
capacity:
storage: 10Gi
persistentVolumeReclaimPolicy: Retain
storageClassName: "" # Static provisioning - must be empty
csi:
driver: blob.csi.azure.com
volumeAttributes:
storageAccount: <STORAGE_ACCOUNT>
containerName: <RUNNER_WORKSPACES_CONTAINER>
volumeHandle: "<RESOURCE_GROUP>#<STORAGE_ACCOUNT>#<RUNNER_WORKSPACES_CONTAINER>"
mountOptions:
- -o allow_other
- --file-cache-timeout-in-seconds=120
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: runner-workspaces-pvc
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi
storageClassName: "" # Static provisioning - must be empty
volumeName: runner-workspaces-pv
# ── Zitadel PAT ───────────────────────────────────────────────────────────────
overlays/production/values.yaml
# StackWeaver Helm Chart - values.yaml
# ─────────────────────────────────────
# Override with --set or -f custom-values.yaml at install time.
#
# SECRETS:
# All secrets must be provided as pre-existing Kubernetes Secrets (via External
# Secrets Operator, Sealed Secrets, or kubectl create secret). The chart only
# references them by name - it never creates Secret resources containing
# user-provided credentials.
#
# Any secret you do not supply is generated in-cluster by the chart's
# secrets-init bootstrap Job, which runs before every other resource and only
# ever creates what is missing. The chart itself never renders Secret manifests.
# ── Global ────────────────────────────────────────────────────────────────────
global:
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# ── Service Account ───────────────────────────────────────────────────────────
serviceAccount:
create: true
name: ""
annotations: {}
# ── Ingress ───────────────────────────────────────────────────────────────────
#
# Two-domain layout:
# - app host: Frontend SPA (login UI bundled via /login/* routes) + API (with auth proxy at /auth/*)
# - auth host: Zitadel OIDC (only when zitadel.bundled=true)
#
# The API is routed via /api/v2, /.well-known/terraform.json, /v1/modules, /health.
# Everything else on the app host goes to the frontend.
ingress:
enabled: true
className: nginx
# Ingress controller provider - selects annotation presets.
# Options: nginx-inc | community-nginx | traefik | none
# nginx-inc: NGINX Inc ingress controller (nginx.org/* annotations) - default
# community-nginx: Community NGINX ingress controller (nginx.ingress.kubernetes.io/*) - sunset project
# traefik: Traefik ingress controller (minimal annotations; use middleware CRDs for advanced config)
# none: No controller-specific annotations - supply everything via annotations/appAnnotations/authAnnotations
provider: nginx-inc
annotations: {}
# Per-ingress annotation overrides (merged on top of provider presets + annotations)
appAnnotations: {}
authAnnotations: {}
tls:
enabled: true
secretName: stackweaver-tls
authSecretName: stackweaver-auth-tls
hosts:
app: stackweaver.example.com
auth: auth.stackweaver.example.com
# ── External Secrets ──────────────────────────────────────────────────────────
#
# For each component, you can either:
# 1. Leave secretName empty → the secrets-init bootstrap Job creates the Secret
# with random values on first install and skips it on every later run, so the
# value is preserved across upgrades. Zero config, works out of the box.
# 2. Set secretName to a pre-existing K8s Secret → chart uses it as-is.
# Use this for production / GitOps (External Secrets Operator, Sealed Secrets, etc.).
#
# The "keys" mapping tells the chart which key name to read from the Secret.
# Only change these if your external Secret uses different key names.
secrets:
# PostgreSQL credentials
# secrets-init generates a random 24-char password when secretName is empty.
postgresql:
secretName: ""
keys:
password: password
# Redis credentials (optional - only needed if Redis auth is enabled)
redis:
secretName: ""
keys:
password: password
# Storage / S3 credentials (only used when storage.auth=credentials)
# When garage.enabled=true AND secretName is empty, secrets-init generates
# credentials that match the bundled Garage init. When using external S3, provide
# your own secret.
# Ignored entirely when storage.auth=pod-identity.
storage:
secretName: ""
keys:
accessKey: access-key
secretKey: secret-key
# Encryption key - 32-byte hex string
# secrets-init generates a random key when secretName is empty.
# Used by API, runner, and ansible-runner for encrypting variables/credentials.
encryption:
secretName: ""
keys:
key: encryption-key
# Zitadel credentials
# secrets-init generates the masterkey + admin password when empty.
# After zitadel-init runs, it populates clientId, clientSecret, etc.
# For external Zitadel: supply all keys directly.
zitadel:
secretName: ""
keys:
masterkey: masterkey
adminPassword: admin-password
adminUsername: admin-username
clientId: client-id
clientSecret: client-secret
loginServiceUserToken: login-service-user-token
webhookIdpSyncKey: webhook-idp-sync-key
webhookComplementTokenKey: webhook-complement-token-key
# OIDC workload identity signing key (optional - for Azure OIDC / inventory sync)
# When secretName is empty, secrets-init generates an RSA-2048 private key.
oidc:
secretName: ""
keys:
signingKey: signing-key
# GitHub App credentials (optional - for GitHub VCS integration)
githubApp:
secretName: ""
keys:
privateKey: private-key
webhookSecret: webhook-secret
# ── Core Components ───────────────────────────────────────────────────────────
# -- API (Go backend, port 8022) --
api:
image:
repository: ghcr.io/vhco-pro/stackweaver-api
tag: latest
pullPolicy: IfNotPresent
replicas: 1
port: 8022
server:
host: "0.0.0.0"
readTimeout: "30s"
writeTimeout: "30s"
corsExtraOrigins: ""
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
# Extra environment variables (map of NAME: value).
# These are added last and can override any auto-generated env var.
env: {}
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# GitHub App integration (non-secret config)
githubApp:
id: ""
name: ""
# -- Frontend (Chainguard nginx SPA, port 8080 - non-root) --
frontend:
image:
repository: ghcr.io/vhco-pro/stackweaver-frontend
tag: latest
pullPolicy: IfNotPresent
replicas: 1
port: 8080
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
env: {}
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# -- Orchestrator (background job scheduler) --
orchestrator:
image:
repository: ghcr.io/vhco-pro/stackweaver-orchestrator
tag: latest
pullPolicy: IfNotPresent
replicas: 1
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
env: {}
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
githubApp:
id: ""
name: ""
# -- OpenTofu Runner --
runner:
image:
repository: ghcr.io/vhco-pro/stackweaver-opentofu-runner
tag: latest
pullPolicy: IfNotPresent
replicas: 1
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
env: {}
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# -- Ansible Runner --
ansibleRunner:
image:
repository: ghcr.io/vhco-pro/stackweaver-ansible-runner
tag: latest
pullPolicy: IfNotPresent
replicas: 1
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
env: {}
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# Size limit for the writable /tmp emptyDir (Ansible local/remote temp, SSH
# control sockets) under the read-only root filesystem.
tmpSizeLimit: 1Gi
# Per-job scratch (playbook checkout, inventory, staged credentials) runs on an
# ephemeral emptyDir isolated from the OpenTofu runner - it holds no run
# history, so nothing is lost when a pod reschedules. Bound its size here.
workspacesSizeLimit: 5Gi
# The Ansible Galaxy cache is a regenerable performance cache. This static-PV
# example keeps it on an ephemeral emptyDir (enabled: false) so it needs no
# extra pre-provisioned volume - collections simply re-download on a cold pod.
# To keep it warm across restarts, set enabled: true and point existingClaim at
# a pre-created PVC (and add it to pv-pvc.yaml).
galaxyCache:
enabled: false
sizeLimit: 5Gi
# ── Storage ───────────────────────────────────────────────────────────────────
# All data (state, configs, registry, logs, ansible artifacts) is stored in a
# single S3-compatible bucket. Any S3-compatible service works: AWS S3, R2, B2,
# MinIO, Garage, DigitalOcean Spaces, etc.
# When garage.enabled=true (default), the chart deploys a bundled Garage instance
# and auto-configures storage to point to it. Set garage.enabled=false to use an
# external S3-compatible service.
storage:
bucket: stackweaver
region: garage
endpoint: ""
useSSL: false
forcePathStyle: true
# Authentication: "credentials" (default) or "pod-identity" (IRSA/Workload Identity)
auth: credentials
serviceAccountAnnotations: {}
# ── Runner Workspaces PVC ─────────────────────────────────────────────────────
runnerWorkspaces:
size: 10Gi
storageClass: ""
accessMode: ReadWriteMany
# The statically provisioned claim defined in pv-pvc.yaml. Set to "" to let the chart create
# a claim from storageClass instead.
existingClaim: runner-workspaces-pvc
# ── Dependencies ──────────────────────────────────────────────────────────────
#
# For each dependency, set enabled=true to deploy it in-cluster (bundled),
# or enabled=false to use an external instance. When external, configure the
# external.* fields and provide credentials in the secrets section above.
# -- PostgreSQL 17 --
postgresql:
enabled: true
image:
repository: postgres
tag: "17"
auth:
username: iac
database: iac_platform
storage:
size: 10Gi
storageClass: ""
existingClaim: "" # if set, skip volumeClaimTemplate and use this pre-existing PVC
volumeName: "" # if set, bind the new PVC to this specific pre-existing PV
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
external:
host: ""
port: 5432
username: ""
database: ""
sslmode: disable
# -- Redis 8 --
redis:
enabled: true
image:
repository: redis
tag: 8-alpine
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
external:
host: ""
port: 6379
# -- Garage (bundled S3-compatible storage) --
# Lightweight, self-hosted, S3-compatible object store. Deployed by default.
# Set enabled=false when using AWS S3, Cloudflare R2, or another external service.
garage:
enabled: true
image:
repository: dxflrs/garage
tag: v2.4.1
storage:
size: 10Gi
storageClass: ""
existingClaim: ""
volumeName: ""
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
# -- Zitadel (OIDC Identity Provider) --
# Zitadel is REQUIRED - the stack cannot function without it.
# Set bundled=true to deploy Zitadel in this chart, or bundled=false to use
# an external Zitadel instance. In both cases, provide credentials in secrets.zitadel.
zitadel:
bundled: true
image:
repository: ghcr.io/zitadel/zitadel
tag: latest
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
config:
accessTokenLifetime: 1h
idTokenLifetime: 12h
refreshTokenIdleExpiration: 720h # 30 days
refreshTokenExpiration: 2160h # 90 days
init:
image:
repository: ghcr.io/vhco-pro/stackweaver-zitadel-init
tag: latest
pullPolicy: IfNotPresent
# Email address for the initial Zitadel admin account.
# Written to the zitadel secret under "admin-username" so operators can discover it
# without reading the docs. Only effective on first install - changing after
# install has no effect (Zitadel FirstInstance config only runs once).
adminUsername: "[email protected]"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
# External Zitadel settings (when bundled=false)
external:
# Public issuer URL (e.g. https://auth.example.com)
issuer: ""
# In-cluster address for gRPC/HTTP calls (e.g. zitadel.other-namespace.svc:8080)
internalAddr: ""
# ── OIDC Workload Identity ───────────────────────────────────────────────────
oidc:
# Issuer URL for OIDC workload identity tokens (non-secret, can be left empty)
issuerUrl: ""
# ── SSO Configuration ─────────────────────────────────────────────────────────
#
# Configure an external identity provider for SSO login.
# Only one of azureAd or oidcProvider can be active at a time (oidcProvider
# covers Okta, AWS Cognito, and any generic OIDC provider).
# Azure AD can be used alongside one oidcProvider.
#
# Non-secret values (client IDs, tenant ID, issuer URL) are set inline.
# Secret values (client secrets) must be provided in a pre-existing K8s Secret.
sso:
enableOidcTeamSync: false
oidcRemoveFromNonSsoTeams: false
# Azure AD / Entra ID
azureAd:
clientId: ""
tenantId: ""
# Generic OIDC provider (Okta, AWS Cognito, Keycloak, etc.)
oidcProvider:
name: "" # Display name on the login button (e.g. "Okta", "AWS Cognito")
issuer: "" # OIDC issuer URL (must serve /.well-known/openid-configuration)
clientId: ""
# Pre-existing K8s Secret containing SSO client secrets.
# Both keys are needed if you configure Azure AD and a Generic OIDC provider simultaneously.
secretName: ""
# Key names within the Secret. Override these if your existing Secret uses
# different key names (e.g. from External Secrets Operator or Sealed Secrets).
keys:
azureAdClientSecret: azure-ad-client-secret
oidcProviderClientSecret: oidc-idp-client-secret
# ── Platform administration ───────────────────────────────────────────────────
#
# A platform administrator manages the installation itself: the OpenTofu
# versions catalogue, the platform administrators, and the list of every
# organization. It has no access inside an organization it is not a member of.
platform:
# Email addresses of the deployment-declared platform administrators. Empty
# means the bundled Zitadel's zitadel.init.adminUsername; with an external
# Zitadel (zitadel.bundled: false) set this, or nobody is a platform
# administrator. Declare only addresses you control at every identity
# provider this installation signs people in with.
admins: []
# Who may create organizations: everyone or platform-admins
organizationCreation: everyone
# ── Custom CA Certificates ─────────────────────────────────────────────────
#
# Mount a custom CA certificate bundle into all Go service containers.
# Required when an upstream service (e.g. an external Zitadel instance) is
# signed by an internal/corporate CA not present in the default system trust
# store - which causes "x509: certificate signed by unknown authority" errors.
#
# Three source options (evaluated in priority order):
#
# existingSecret - name of a pre-existing Kubernetes Secret
# existingConfigMap - name of a pre-existing Kubernetes ConfigMap
# cert - PEM-encoded certificate(s) pasted inline; the chart
# creates a ConfigMap from this value automatically
#
# The cert is mounted at /etc/ssl/certs/custom-ca.crt via subPath (so the
# system cert directory is not replaced). Go's crypto/x509 package scans
# /etc/ssl/certs/ automatically on Linux, so no extra env vars are needed.
customCA:
# Inline PEM-encoded CA certificate(s). The chart creates a ConfigMap from
# this value. Ignored when existingSecret or existingConfigMap is set.
# Example (use | to preserve newlines in YAML):
# cert: |
# -----BEGIN CERTIFICATE-----
# ...
# -----END CERTIFICATE-----
cert: ""
# Name of a pre-existing Kubernetes ConfigMap containing the CA cert.
# Ignored when existingSecret is set.
existingConfigMap: ""
# Name of a pre-existing Kubernetes Secret containing the CA cert.
# Takes highest priority when set.
existingSecret: ""
# Key within the Secret or ConfigMap that holds the PEM-encoded cert(s).
key: ca.crt
Change these inputs before a sync:
overlays/production/kustomization.yaml: reviewed chart version and release namespace.overlays/production/values.yaml: app/auth hosts, ingress provider/class, TLS Secret names, image tags/digests andglobal.imagePullSecretswhen needed.overlays/production/pv-pvc.yaml: storage driver, volume identity and claim for your cluster. The Azure-specific identifiers are placeholders, not portable storage defaults.runnerWorkspaces.existingClaim: must match the runner claim,runner-workspaces-pvcin this example. Replace the static volume resources with your storage path if different.
Keep search indexing disabled for private environments. If an overlay publishes public
pages, configure frontend.publicSite in its chart values and follow the
public-site checks before submitting a sitemap.
This example disables the persistent Galaxy cache, so a restarted Ansible runner downloads collections again. PostgreSQL and Garage still need durable storage; a runner scratch claim is not a database backup.
flowchart LR
G["Git: base and environment values"] --> A["Argo CD: Kustomize with Helm enabled"]
G --> F["Flux: Kustomize of HelmRelease resources"]
A --> K["Kubernetes resources and hooks"]
F --> H["Flux Helm controller"]
H --> KRender locally before syncing
With an OCI-capable Kustomize (for example 5.7.1) and Helm installed, from the extracted example root:
kustomize build overlays/production --enable-helm > rendered.yaml
Use a Kustomize version that supports your OCI chart source, and verify its Helm command can pull the selected version. Review the output's namespace, hosts, image references, PVCs and hook annotations. This renders files; it does not create resources. Protect rendered output if you add secrets or sensitive env values.
Argo CD
An Argo administrator must enable Helm inflation in the argocd-cm ConfigMap. Merge this
setting with existing build options rather than replacing unrelated configuration:
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
kustomize.buildOptions: --enable-helm
This is a repo-server/global build setting, not a per-Application kustomize.enableHelm
field. A custom config-management plugin is another option when a global setting is unsuitable.
See Argo CD's Kustomize guidance.
Save an Application in your deployment repository, substituting its URL and overlay path:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: stackweaver
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-org/your-gitops-repo
targetRevision: main
path: overlays/production
destination:
server: https://kubernetes.default.svc
namespace: stackweaver
syncPolicy:
syncOptions:
- CreateNamespace=true
Review the diff and perform the first sync manually. The chart uses PreSync secret bootstrap and PostSync identity-policy reconciliation. Do not assume a selective resource sync executes all hooks. Enable automated pruning/self-healing only after agreeing how deletions and secret ownership should be handled.
Flux
Flux's Kustomize controller does not execute the Helm inflation plugin. Use its native Helm
resources. The following belongs in a directory referenced by your existing Flux
GitRepository/Kustomization; create namespace stackweaver and prerequisite Secrets first.
Replace REPLACE_WITH_CHART_VERSION with the exact selected chart tag:
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: stackweaver
namespace: stackweaver
spec:
interval: 10m
url: oci://ghcr.io/vhco-pro/charts/stackweaver
ref:
tag: REPLACE_WITH_CHART_VERSION
layerSelector:
mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip
operation: copy
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: stackweaver
namespace: stackweaver
spec:
interval: 10m
releaseName: stackweaver
chartRef:
kind: OCIRepository
name: stackweaver
values:
global:
imagePullSecrets:
- name: ghcr-pull-secret
ingress:
provider: nginx-inc
className: nginx
hosts:
app: stackweaver.example.com
auth: auth.stackweaver.example.com
tls:
enabled: true
secretName: stackweaver-tls
authSecretName: stackweaver-auth-tls
Copy your complete environment values into spec.values, or reference an appropriately
managed values source. The local Kustomize valuesFile does not automatically become Flux
HelmRelease values. Include your storage/secret overrides as well. For full source and
release options, use Flux's Helm guide.
Browser-session upgrades
Before a GitOps upgrade with external Zitadel, create the confidential BFF client and
populate its required Secret keys. Pin the reviewed per-component image versions or use
Always so a cached initializer cannot leave the new API without credentials. Follow
the Helm upgrade sequence, including
readiness checks and rollback prerequisites; do not let an automatic rollback conceal missing keys.
Secrets and first-sync checks
The chart's bootstrap Job creates missing Secrets without overwriting existing values.
For production lifecycle control, provision credentials through your secret store/operator
and reference them through secrets.<component>.secretName. To omit bootstrap entirely,
supply all five credential Secrets and disable bundled Garage; see
Helm secret ownership. Kubernetes RBAC and hook ordering still matter.
Do not commit live passwords or unencrypted pull credentials in Git. The chart's credential references do not make arbitrary values you add to manifests secret.
After syncing, check:
kubectl get pods,pvc --namespace stackweaver
kubectl logs --namespace stackweaver job/stackweaver-secrets-init
kubectl logs --namespace stackweaver deployment/stackweaver-zitadel -c zitadel-init
If you intentionally omitted bootstrap or use external identity, skip the absent Job/container and verify that path's supplied credentials. Check the controller's Ready/health conditions, then sign in through the configured HTTPS app hostname. On a failed sync, inspect image pulls, PVC binding and hook logs before retrying; do not delete Secrets to force a clean sync.