Loading learning content...
A fundamental promise of GitOps is that your Git repository becomes a complete description of your cluster state. If your repository disappears, your cluster documentation is gone. Conversely, if your cluster disappears, you can recreate it entirely from Git. This promise requires modeling everything declaratively—not just application deployments, but infrastructure components, networking, security policies, observability stacks, and cross-cutting concerns.
Achieving this level of declarative completeness is both technically challenging and culturally transformative. It requires rethinking how we manage configuration, secrets, and the inevitable edge cases that seem to require imperative intervention.
This page teaches you how to structure a fully declarative cluster state. You'll learn to organize infrastructure layers, manage secrets safely in Git, handle cross-cutting concerns, and design a repository structure that scales from single-cluster to enterprise multi-cluster deployments.
A Kubernetes cluster isn't a flat collection of resources—it has layers with dependencies, ownership boundaries, and different change velocities. Understanding these layers is essential for structuring your GitOps repository effectively.
The Infrastructure Layer Cake:
Think of cluster state as a layered cake, where each layer depends on the layers below it:
Why Layers Matter:
Dependency Ordering: You can't deploy an Ingress before the Ingress Controller exists. You can't create a Certificate before cert-manager is running. Layers encode these dependencies.
Change Velocity: Infrastructure changes infrequently (weekly/monthly). Applications change constantly (hourly/daily). Separating layers prevents high-velocity application changes from affecting slow-moving infrastructure.
Ownership Boundaries: The platform team owns layers 1-4. Product teams own layer 6. Clear ownership prevents conflicts and enables parallel workflows.
Blast Radius Containment: A broken application deployment affects one team. A broken infrastructure deployment affects everyone. Separation allows different testing and approval requirements.
Both ArgoCD and Flux support explicit dependencies. In ArgoCD, use sync waves and the app-of-apps pattern. In Flux, Kustomizations can declare dependsOn relationships. These mechanisms ensure layers are applied in the correct order, even when manifests are committed simultaneously.
Infrastructure components require careful declarative modeling. Unlike applications with simple Deployment + Service patterns, infrastructure often involves CRDs, cluster-scoped resources, and complex configurations.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
# Infrastructure Layer Organizationinfrastructure/├── base/│ ├── namespaces/│ │ ├── kustomization.yaml│ │ ├── ingress-nginx.yaml│ │ ├── cert-manager.yaml│ │ ├── monitoring.yaml│ │ └── security.yaml│ ││ ├── crds/│ │ ├── kustomization.yaml│ │ └── README.md # CRDs often installed by operators│ ││ ├── ingress-nginx/│ │ ├── kustomization.yaml│ │ ├── namespace.yaml│ │ ├── helmrelease.yaml # Or raw manifests│ │ └── ingress-class.yaml│ ││ ├── cert-manager/│ │ ├── kustomization.yaml│ │ ├── namespace.yaml│ │ ├── helmrelease.yaml│ │ └── cluster-issuers/│ │ ├── letsencrypt-staging.yaml│ │ └── letsencrypt-production.yaml│ ││ ├── external-secrets/│ │ ├── kustomization.yaml│ │ ├── namespace.yaml│ │ ├── helmrelease.yaml│ │ └── cluster-secret-store.yaml│ ││ └── monitoring/│ ├── kustomization.yaml│ ├── prometheus/│ │ ├── kustomization.yaml│ │ └── helmrelease.yaml│ └── grafana/│ ├── kustomization.yaml│ ├── helmrelease.yaml│ └── dashboards/│ ├── kubernetes-cluster.yaml│ └── nginx-ingress.yaml│└── overlays/ ├── production/ │ ├── kustomization.yaml # Patches for prod │ ├── ingress-nginx-patch.yaml │ └── prometheus-patch.yaml └── staging/ ├── kustomization.yaml └── ingress-nginx-patch.yamlHelm Charts in GitOps:
Many infrastructure components are distributed as Helm charts. In GitOps, you have two options:
Option 1: Render and Commit (Template in CI)
helm template ingress-nginx ingress-nginx/ingress-nginx \
--values values-production.yaml \
> manifests/ingress-nginx.yaml
Pros: Full visibility into generated manifests, easy diffing, no Helm at runtime. Cons: Upgrade process is more complex, generated files can be large.
Option 2: GitOps Operator Renders (HelmRelease CRD) The GitOps operator (ArgoCD or Flux's helm-controller) renders charts at sync time. Pros: Simpler workflow, native Helm features, easier upgrades. Cons: Less visibility pre-deployment, requires trusting runtime rendering.
Both approaches are valid. Option 2 is more common for infrastructure because it's simpler and infrastructure changes are heavily reviewed anyway.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
# HelmRelease for ingress-nginx (Flux style)apiVersion: helm.toolkit.fluxcd.io/v2beta2kind: HelmReleasemetadata: name: ingress-nginx namespace: ingress-nginxspec: interval: 1h chart: spec: chart: ingress-nginx version: "4.8.x" sourceRef: kind: HelmRepository name: ingress-nginx namespace: flux-system interval: 24h install: crds: CreateReplace remediation: retries: 3 upgrade: crds: CreateReplace remediation: retries: 3 remediateLastFailure: true cleanupOnFail: true values: controller: replicaCount: 3 # Resource requests/limits resources: requests: cpu: 100m memory: 256Mi limits: cpu: 500m memory: 512Mi # High availability affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app.kubernetes.io/name: ingress-nginx topologyKey: kubernetes.io/hostname # Service configuration service: type: LoadBalancer externalTrafficPolicy: Local annotations: service.beta.kubernetes.io/aws-load-balancer-type: nlb service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true" # Metrics for monitoring metrics: enabled: true serviceMonitor: enabled: true namespace: monitoring # Security admissionWebhooks: enabled: true # Default backend defaultBackend: enabled: true replicaCount: 2Custom Resource Definitions (CRDs) require special handling. They must exist before any Custom Resources that use them. Some Helm charts include CRDs; others install them separately. Flux's install.crds and upgrade.crds options control this behavior. ArgoCD's sync waves can order CRD installation. Get this wrong, and deployments fail silently waiting for nonexistent CRDs.
Secrets are the Achilles' heel of GitOps. The fundamental promise is "everything in Git," but you cannot commit plaintext secrets to Git. This creates a tension that requires careful solution architecture.
The Secret Management Spectrum:
| Solution | Approach | Pros | Cons |
|---|---|---|---|
| Sealed Secrets | Encrypt with cluster public key, commit to Git | Simple, no external deps, GitOps-native | Secrets tied to cluster, key rotation complex |
| SOPS | Encrypt with KMS/PGP, commit to Git | Multi-key support, flexible, works offline | Requires decryption step, key management |
| External Secrets Operator | Sync from Vault/AWS/GCP to K8s Secrets | Centralized secret management, rotation | External dependency, network requirement |
| Vault Agent Injector | Inject secrets via webhook at pod start | Dynamic secrets, fine-grained access | Vault dependency, performance impact |
| CSI Secret Store | Mount secrets as volumes from providers | Standard interface, multiple backends | CSI driver complexity, mount semantics |
Bitnami Sealed Secrets encrypts secrets using the cluster's public key. Only the Sealed Secrets controller running in the cluster can decrypt them.
1234567891011121314
# Create a regular Kubernetes secretkubectl create secret generic db-credentials \ --from-literal=username=myuser \ --from-literal=password=supersecret \ --dry-run=client -o yaml > secret.yaml # Encrypt it using the cluster's public keykubeseal --format yaml \ --controller-namespace kube-system \ --controller-name sealed-secrets-controller \ < secret.yaml > sealed-secret.yaml # The sealed-secret.yaml is SAFE to commit to Git!cat sealed-secret.yamlEncrypted-in-Git solutions (Sealed Secrets, SOPS) require re-encryption and a new commit when secrets rotate. External secret stores (Vault, AWS Secrets Manager) separate rotation from Git—secrets update in the backend, and the operator syncs the new values automatically. For high-rotation secrets, external stores are strongly preferred.
Applications are the highest layer in your declarative cluster state. They change most frequently and are typically owned by product teams rather than platform teams. The patterns for organizing application state need to balance developer autonomy with cluster-wide consistency.
1234567891011121314151617181920212223242526272829303132333435363738394041
# Application Layer Organizationapps/├── my-service/│ ├── base/│ │ ├── kustomization.yaml│ │ ├── deployment.yaml│ │ ├── service.yaml│ │ ├── hpa.yaml│ │ ├── pdb.yaml│ │ ├── configmap.yaml│ │ └── service-account.yaml│ ││ └── overlays/│ ├── development/│ │ ├── kustomization.yaml│ │ ├── replicas-patch.yaml # 1 replica│ │ └── resources-patch.yaml # Lower limits│ ││ ├── staging/│ │ ├── kustomization.yaml│ │ ├── replicas-patch.yaml # 2 replicas│ │ └── ingress.yaml # Staging domain│ ││ └── production/│ ├── kustomization.yaml│ ├── replicas-patch.yaml # 5 replicas│ ├── ingress.yaml # Production domain│ ├── resources-patch.yaml # Higher limits│ └── external-secret.yaml # Prod secrets│├── another-service/│ └── ...│└── _templates/ ├── web-service/ # Reusable template │ ├── kustomization.yaml │ ├── deployment.yaml │ └── service.yaml └── background-worker/ ├── kustomization.yaml └── deployment.yamlKustomize Overlay Pattern:
The base + overlay pattern is fundamental to GitOps. The base contains the complete, production-ready configuration. Overlays make minimal, targeted modifications for different environments.
Key Principles:
Base must be deployable — The base should work in production. Overlays simplify for lower environments, not augment for production.
Overlays are minimal — Only patch what differs. Don't duplicate entire files; use Kustomize patches.
Environment parity — The more overlays diverge from base, the less confidence you have that staging reflects production.
Consistent naming — Use the same namespace names across environments when possible. Different clusters, same logical structure.
12345678910111213141516171819202122232425
# apps/my-service/base/kustomization.yamlapiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomization namespace: my-service # Common labels for all resourcescommonLabels: app.kubernetes.io/name: my-service app.kubernetes.io/component: backend app.kubernetes.io/part-of: my-platform resources: - deployment.yaml - service.yaml - hpa.yaml - pdb.yaml - configmap.yaml - service-account.yaml # Images can be overridden by overlays or GitOps toolsimages: - name: my-service newName: registry.example.com/my-service newTag: latest # Overridden by image automationImage tags should be updated in exactly one place. With GitOps image automation (Flux Image Automation or Argo Image Updater), the tool commits updated tags to your repository. Without automation, CI pipelines should update a single file (typically the overlay's kustomization.yaml) that specifies the image tag.
Many cluster resources cut across the layer model—they're used by multiple applications or affect the entire cluster. Managing these requires careful design to avoid duplication while maintaining clear ownership.
Pattern: Policy with Defaults and Overrides
A common pattern for cross-cutting concerns:
For example, with network policies:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
# Platform team defines defaults# infrastructure/security/default-network-policy.yamlapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: default-deny-ingress # Applied to all namespaces via Kustomize namespaceSuffixspec: podSelector: {} # All pods policyTypes: - Ingress ingress: [] # Deny all ingress by default ---# Allow intra-namespace communicationapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-same-namespacespec: podSelector: {} policyTypes: - Ingress ingress: - from: - podSelector: {} # Same namespace ---# Application team adds their specific rules# apps/my-service/base/network-policy.yamlapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: my-service-ingressspec: podSelector: matchLabels: app: my-service policyTypes: - Ingress ingress: # Allow from ingress controller - from: - namespaceSelector: matchLabels: name: ingress-nginx podSelector: matchLabels: app.kubernetes.io/name: ingress-nginx ports: - protocol: TCP port: 8080 # Allow from specific consuming service - from: - namespaceSelector: matchLabels: name: consumer-service podSelector: matchLabels: app: consumer-serviceFor complex policy requirements, dedicated policy engines like OPA Gatekeeper or Kyverno provide more power than raw Kubernetes RBAC and admission control. They can enforce policies like 'all Deployments must have resource limits', 'all images must come from approved registries', or 'all pods must have security contexts'. These engines integrate well with GitOps.
Enterprise deployments typically span multiple clusters—different regions, environments, or even cloud providers. Extending declarative state management across clusters requires patterns for sharing configuration while allowing cluster-specific customization.
12345678910111213141516171819202122232425262728293031323334353637383940
# Multi-Cluster Repository Organizationfleet-infra/├── clusters/│ ├── production-us-east/│ │ ├── flux-system/ # Flux installation for this cluster│ │ ├── infrastructure.yaml # Points to infrastructure overlays│ │ ├── apps.yaml # Points to app overlays│ │ └── cluster-config.yaml # Cluster-specific variables│ ││ ├── production-eu-west/│ │ ├── flux-system/│ │ ├── infrastructure.yaml│ │ ├── apps.yaml│ │ └── cluster-config.yaml│ ││ ├── staging-us-east/│ │ └── ...│ ││ └── development/│ └── ...│├── infrastructure/│ ├── base/ # Shared infrastructure base│ └── overlays/│ ├── production/ # Production-specific│ └── staging/ # Staging-specific│├── apps/│ ├── service-a/│ │ ├── base/│ │ └── overlays/│ │ ├── production/ # Shared across prod clusters│ │ └── staging/│ └── service-b/│ └── ...│└── cluster-definitions/ ├── production-us-east.yaml # Cluster metadata ├── production-eu-west.yaml └── staging-us-east.yamlPatterns for Multi-Cluster Configuration:
1. Inheritance Model Common base configurations with cluster-specific overlays. Each cluster's directory contains only what's unique to that cluster.
2. Variable Substitution
Flux's postBuild.substitute allows variables to be replaced based on per-cluster ConfigMaps. Define ${CLUSTER_NAME}, ${REGION}, ${ENVIRONMENT} variables that are substituted at reconciliation time.
3. Generator Pattern (ArgoCD) ApplicationSets use generators to create Applications for each cluster. A matrix generator can create apps × clusters combinations from a single definition.
4. Hub-and-Spoke A management cluster hosts the GitOps operator, deploying to multiple workload clusters. Common in regulated environments where separation is required.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
# Cluster-specific configuration (stored per-cluster)# clusters/production-us-east/cluster-config.yamlapiVersion: v1kind: ConfigMapmetadata: name: cluster-config namespace: flux-systemdata: CLUSTER_NAME: production-us-east REGION: us-east-1 ENVIRONMENT: production INGRESS_DOMAIN: us-east.production.example.com REPLICA_MINIMUM: "3" ---# Kustomization with variable substitution# clusters/production-us-east/apps.yamlapiVersion: kustomize.toolkit.fluxcd.io/v1kind: Kustomizationmetadata: name: apps namespace: flux-systemspec: interval: 10m sourceRef: kind: GitRepository name: fleet-infra path: ./apps/service-a/overlays/production prune: true # Substitute variables from cluster-config postBuild: substitute: CLUSTER_NAME: production-us-east # Fallback substituteFrom: - kind: ConfigMap name: cluster-config ---# Application manifest can reference variables# apps/service-a/overlays/production/deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata: name: service-a annotations: cluster: "${CLUSTER_NAME}" region: "${REGION}"spec: replicas: ${REPLICA_MINIMUM} # Substituted at reconcile time template: spec: containers: - name: service-a env: - name: CLUSTER_NAME value: "${CLUSTER_NAME}"ApplicationSets excel at multi-cluster management. Using the cluster generator, a single ApplicationSet can deploy an application to all registered clusters. Combined with the matrix generator, you can create every combination of app × environment × cluster from one definition.
We've explored how to model your entire cluster as declarative, Git-versioned state—from infrastructure layers through application workloads. Let's consolidate the key patterns:
What's Next:
With your cluster state modeled declaratively, the next page covers automated reconciliation—the continuous loop that ensures your live clusters match the desired state in Git, including drift detection, health assessment, and failure handling.
You now understand how to structure a fully declarative cluster state, from infrastructure layers to application workloads, with proper secret management and multi-cluster patterns. Next, we'll explore automated reconciliation—the engine that keeps your cluster in sync with Git.