GitOps

GitOps repository structure for Kubernetes applications

Separate application source, deployment values, environments, and platform configuration with a bridge GitOps repository, Argo CD path generators, and a pinned Universal Helm Chart dependency.

Chart fidelity

Wrapper values use Universal Helm Chart application 0.4.3 keys only (values.yaml, Artifact Hub). Evidence pack: examples/gitops-repository-structure/ (this MR branch until merge). Replace example images and hostnames; remotes use example.com.

Evidence classes

Verified: shapes measured in the 2026-07-23 battle matrix (BATTLE_TEST_RESULTS.md) and the DEV-159 delivery package. Illustrative: production hardening (jitter retry, stage ring, SHA pins for CI include.ref) that is correct but not a private-lab fact. Case-study: operator-reported “60+/90+ services” figures—not re-measured here; do not treat as Verified.

Versions cited

Scope

This article is the repository topology and promotion playbook (bridge GitOps, ApplicationSets, CI mutate-image-only). Chart ownership: One Helm chart vs many. Primer: Why one Helm chart is enough. Companion layout: examples/gateway-api-helm-contract/BRIDGE-GITOPS-LAYOUT.md.

Example pack

Layout and excerpts: examples/gitops-repository-structure/ (see README.md).

Four topologies

Full trade-off table: examples/gitops-repository-structure/TOPOLOGY-COMPARISON.md.

Layer contract

Service repo          Bridge GitOps repo              Cluster
─────────────         ──────────────────              ───────
app + Dockerfile  →   environments/<env>/<app>/
shared CI include →   Chart.yaml → application@0.4.3  → Argo ApplicationSet
build/push image  →   values.yaml (image + imageTag)  → Synced/Healthy
                      argocd/appset-*.yaml
                      environments/infra/...            → Gateway / SecretStores

Platform owns ApplicationSets, AppProjects, the shared CI template, and the universal chart contract. App teams own the service repository and MRs that change their values. Argo CD reconciles; it does not author desired state.

Concrete bridge tree

gitops-bridge/
├── .ci/.gitlab-ci.yml
├── argocd/
│   ├── appset-dev.yaml
│   ├── appset-stage.yaml          # Illustrative ring
│   ├── appset-prod.yaml
│   └── appproject-prod.yaml
├── environments/
│   ├── dev/demo-api/{Chart.yaml,values.yaml}
│   ├── stage/demo-api/{Chart.yaml,values.yaml}
│   ├── prod/demo-api/{Chart.yaml,values.yaml}
│   └── infra/shared-gateway/...
└── templates/app-onboard/

Minimal checked-in tree: examples/gitops-repository-structure/bridge-tree/. Battle-lab path counts (Verified): 13 under dev/, 6 under prod/, 19 under infra/. Do not equate those with case-study “60+/90+ services” claims—those remain operator-reported, not re-measured in this pack.

Wrapper Chart.yaml and values contract

# Full files:
# examples/gitops-repository-structure/bridge-tree/environments/prod/demo-api/{Chart.yaml,values.yaml}
dependencies:
  - name: application
    version: "0.4.3"
    repository: https://chaser100.github.io/u-helm-chart

# values.yaml (abbreviated — image/route/envSecrets only)
application:
  image: ghcr.io/example-org/demo-api
  imageTag: "1.2.0"
  route:
    enabled: true
    gateway: external
    gatewayNamespace: kgateway-system
    sectionName: https-wildcard
    hostname: demo-api.example.com
  envSecrets:
    enableEnv: true
    envs:
      - name: DEMO_API_DB_PASSWORD
        secretName: demo-api-runtime
        secretKey: db-password

CI may mutate only application.image and application.imageTag (Verified contract). Probes, resources, route hostnames, and secret references stay in Git as reviewed config. Runtime excerpt: examples/gitops-repository-structure/ci/values-runtime.excerpt.yaml.

Argo CD ApplicationSet (path generator)

# Abbreviated Verified shape — full file:
# examples/gitops-repository-structure/argocd/applicationset-dev.excerpt.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: appset-dev
  namespace: argocd
spec:
  generators:
    - git:
        repoURL: https://gitlab.example.com/platform/gitops-bridge.git
        revision: HEAD
        directories:
          - path: environments/dev/*
  template:
    metadata:
      name: 'dev-{{ .path.basenameNormalized }}'
    spec:
      project: dev
      source:
        repoURL: https://gitlab.example.com/platform/gitops-bridge.git
        path: '{{ .path.path }}'
        targetRevision: HEAD
        helm:
          valueFiles: [values.yaml, values.configmaps.yaml, values.extra.yaml]
          ignoreMissingValueFiles: true
      destination:
        server: https://kubernetes.default.svc
        namespace: dev
      syncPolicy:
        automated: { prune: true, selfHeal: true }

Onboard by adding environments/<env>/<app>/; the generator creates dev-<app>. Put CRDs, SecretStores, and Gateways in an infra ApplicationSet (or lower sync-wave) before app routes. Keep source.repoURL on the Application template—omitting it is not a valid Helm ApplicationSet source.

GitLab CI: shared include and concurrent updates

# Illustrative service-repo include — file:
# examples/gitops-repository-structure/ci/app-repo.gitlab-ci.yml
include:
  - project: 'platform/gitops-bridge'
    file: '/.ci/.gitlab-ci.yml'
    ref: main   # pin to tag/SHA in production (Illustrative hardening)

variables:
  IMAGE_BUILD_TOOL: buildkit
  IMAGE_UPDATE_METHOD: git_commit

Verified battle baseline: about five retries with git fetch + git rebase on push rejection, without sleep/jitter.

Illustrative hardening—bounded attempts, full jitter, fail-closed:

MAX_ATTEMPTS=5
BASE_SLEEP_SEC=2
for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
  if git push origin "HEAD:${GITOPS_BRANCH}"; then exit 0; fi
  git fetch origin "${GITOPS_BRANCH}"
  git rebase "origin/${GITOPS_BRANCH}"
  exp=$(( BASE_SLEEP_SEC * (1 << (attempt - 1)) ))
  [ "${exp}" -gt 30 ] && exp=30
  sleep_ms=$(( RANDOM % (exp * 1000 + 1) ))
  sleep "$(awk -v ms="${sleep_ms}" 'BEGIN { printf "%.3f", ms/1000 }')"
done
exit 1

Use the pattern above in the CI GitOps updater job. Optional: GitLab resource_group per hot values path to serialize concurrent writers.

Promotion metadata

Battle evidence: six languages × two environments → twelve Applications Synced/Healthy; HTTPRoutes Accepted=True and ResolvedRefs=True. Manual prod rollback remained deferred—practice the runbook.

Gateway API ownership (kgateway)

Platform owns the shared Gateway (listeners, TLS certificateRefs, allowedRoutes). Apps own HTTPRoutes through chart route.*—exact hostnames, not wildcards. Cross-namespace attachment is gated by listener allowedRoutes (and matching sectionName), not by ReferenceGrant. Use ReferenceGrant only for cross-namespace backends (Service) or TLS Secret refs—grant lives in the target namespace.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: external
  namespace: kgateway-system
spec:
  gatewayClassName: kgateway
  listeners:
  - name: https-wildcard
    protocol: HTTPS
    port: 443
    hostname: "*.example.com"
    tls:
      mode: Terminate
      certificateRefs: [{ name: wildcard-example-tls }]
    allowedRoutes:
      namespaces:
        from: Selector
        selector:
          matchLabels: { allow-external-gateway: "true" }

Manifests: examples/gitops-repository-structure/gateway/. During migration keep ingress-nginx and Gateway hostnames distinct—see ingress-coexistence.yaml and Ingress NGINX to kgateway. App wrappers must not enable production gatewayResource.

Secrets, RBAC, CODEOWNERS

Details: examples/gitops-repository-structure/OWNERSHIP-CODEOWNERS.md.

Drift, rollback, pinning, disaster recovery

Runbook: examples/gitops-repository-structure/PROMOTION-ROLLBACK.md.

Failure modes

Official documentation

Why one Helm chart is enough → · One Helm chart vs many → · kgateway migration patterns → · Gateway API examples → · Ingress NGINX to kgateway →