Skip to content
Published on

Managing Ingress as Code — Helm, Kustomize, and GitOps

Authors

Introduction

Ingress configuration is not a static thing you set up once and forget. You renew TLS certificates, tune timeouts and body-size limits, add rate-limit annotations, and add a path every time a new service appears. And all of these changes must apply with subtly different values across dev, staging, and production. The moment someone fixes production directly with kubectl edit ingress, that change is destined to vanish without any record.

That is exactly why Ingress is a prime candidate for being managed as code. Both the controller deployment itself (which version, with what resources, with which ConfigMap) and the individual Ingress manifests (hosts, paths, annotations, TLS) should live in Git and be applied declaratively to the cluster. This is the core value of GitOps: Git becomes the single source of truth, and cluster state always converges toward Git.

This article breaks Ingress version control into four stages. First, deploying controllers per environment with Helm values. Second, templating Ingress manifests with Kustomize and layering per-environment patches. Third, automating delivery and drift detection with Argo CD and Flux. Fourth, standing up gates with a policy engine like Kyverno to enforce required annotations and IngressClass. As of 2026 the Ingress API is frozen and the Gateway API is the successor standard, but it is worth noting that the version-control patterns covered here apply equally to both APIs.

What needs version control

In the Ingress space, what belongs in Git falls into two broad layers.

LayerSubjectChange frequencyTooling
PlatformController deployment, ConfigMap, IngressClassLow (quarterly)Helm values
ApplicationIngress manifests (host/path/TLS/annotations)High (constant)Kustomize/Helm

Separating these two matters. The platform team carefully manages controller versions and security patches, while each application team frequently changes its own Ingress manifests to fit its service. Mixing both layers into one giant chart widens the blast radius of changes and makes review harder.

  Git repository
  ├── platform/ingress-controller/   (Helm values, per environment)
  │     ├── values-base.yaml
  │     ├── values-dev.yaml
  │     └── values-prod.yaml
  └── apps/<service>/ingress/        (Kustomize base + overlays)
        ├── base/
        └── overlays/{dev,staging,prod}/

Deploying the controller with Helm

The major controllers, ingress-nginx, Traefik, and others, all provide official Helm charts. The key is to keep different values per environment while collecting the common parts in base values.

A base values example.

controller:
  replicaCount: 2
  ingressClassResource:
    name: nginx
    default: false
  config:
    use-forwarded-headers: "true"
    proxy-body-size: "16m"
  resources:
    requests:
      cpu: 100m
      memory: 128Mi

The production overlay scales up replicas and resources and spells out the external exposure method.

controller:
  replicaCount: 4
  service:
    type: LoadBalancer
    annotations:
      service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
  resources:
    requests:
      cpu: 500m
      memory: 512Mi
  config:
    proxy-body-size: "64m"

Deploy by layering the two values files.

helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx --create-namespace \
  -f values-base.yaml -f values-prod.yaml

This way the difference between production and dev is clearly visible in a small overlay file, so a question like "why does only this environment have a different body-size limit?" can be answered immediately with a Git diff.

Templating Ingress manifests with Kustomize

Individual Ingress manifests are a good fit for Kustomize. You keep the common structure in base and change only the host, TLS, or annotations per environment with patches.

The base Ingress.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
spec:
  ingressClassName: nginx
  rules:
    - host: web.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

The base kustomization.

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ingress.yaml

The production overlay changes host and TLS with a patch.

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
patches:
  - target:
      kind: Ingress
      name: web
    patch: |-
      - op: replace
        path: /spec/rules/0/host
        value: web.prod.example.com
      - op: add
        path: /spec/tls
        value:
          - hosts:
              - web.prod.example.com
            secretName: web-prod-tls

If you want to add common annotations to all Ingresses at once, you can use commonAnnotations.

commonAnnotations:
  team: platform
  managed-by: kustomize

The advantage of this pattern is that base is the single source of truth. Since all environments share base, fixing common logic in one place reflects consistently across every environment.

Delivery and drift detection with Argo CD

Now it is time to automatically apply the Git-organized manifests to the cluster. Argo CD declares "sync this Git path to this cluster/namespace" with an Application resource.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: web-ingress-prod
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/example/infra.git
    targetRevision: main
    path: apps/web/ingress/overlays/prod
  destination:
    server: https://kubernetes.default.svc
    namespace: web
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

The key is selfHeal: true and prune: true. If someone changes the cluster's Ingress directly with kubectl edit, Argo CD detects it as drift and reverts to the Git state. If you delete a resource in Git, it is removed from the cluster. This maintains the "cluster = Git" invariant.

If you use Flux, you do the same with a Kustomization resource.

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: web-ingress
  namespace: flux-system
spec:
  interval: 5m
  path: ./apps/web/ingress/overlays/prod
  prune: true
  sourceRef:
    kind: GitRepository
    name: infra

Every interval: 5m it compares Git and the cluster and reconciles any difference. The essence of drift detection is the same.

Policy gates — enforcing with Kyverno

GitOps alone does not prevent "a bad Ingress being merged into Git" in the first place. So you place a policy gate right before entry into the cluster. Kyverno works as an admission webhook, rejecting or mutating resources that violate rules.

First, a policy enforcing an IngressClass on every Ingress. Without a class it is ambiguous which controller handles it, so this is blocked.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-ingress-class
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-ingress-class
      match:
        any:
          - resources:
              kinds:
                - Ingress
      validate:
        message: "Every Ingress must specify spec.ingressClassName."
        pattern:
          spec:
            ingressClassName: "?*"

Second, a policy requiring a security-mandatory annotation (for example, forcing SSL redirect).

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-ssl-redirect
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-ssl-redirect
      match:
        any:
          - resources:
              kinds:
                - Ingress
      validate:
        message: "Ingress requires the force-ssl-redirect annotation."
        pattern:
          metadata:
            annotations:
              nginx.ingress.kubernetes.io/force-ssl-redirect: "true"

With such policies, you can block at the cluster level the accidental creation of an Ingress that exposes only plaintext HTTP or has no class. GitOps (guaranteeing the desired state) and a policy engine (enforcing the allowed state) are complementary.

Multi-environment and multi-cluster

As scale grows there are many environments and many clusters. Argo CD spreads the same pattern across multiple targets with ApplicationSet. For example, you put a cluster list in a generator and automatically deploy the same Ingress overlay to each cluster. The principle is the same here too: share base and express only per-cluster differences as overlays.

Secret (TLS) management

TLS certificates are the tricky part of version control, because you cannot put a plaintext Secret directly in Git. In practice two approaches are standard.

  • Automatic issuance with cert-manager: specify an issuer in the Ingress annotation, and cert-manager issues and renews certificates via ACME (Let's Encrypt and so on). The certificate itself is not in Git; only the declaration is.
  • Encrypted secrets: keep only an encrypted form in Git via an external secret manager or Sealed Secrets, and decrypt in the cluster.

An Ingress annotation example for the cert-manager approach.

metadata:
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"

This way even certificate renewal is managed declaratively, eliminating the manual scramble in response to expiry-imminent alerts.

Review process

The real value of GitOps comes from changes going through a Pull Request. A recommended review flow for Ingress changes.

1. Edit the overlay patch on a branch
2. Validate schema in CI with kustomize build + kubeconform
3. Pre-check policies in CI with the Kyverno CLI
4. Auto-post the Argo CD diff as a PR comment (preview the change before apply)
5. Merge after peer review -> Argo CD syncs automatically

The key is "see before you apply." Attaching the Argo CD diff to the PR lets a human visually confirm what change the merge will cause in the cluster before approving.

Checklist

[ ] Did you separate controller (platform) and Ingress (app) version control
[ ] Do you deploy the controller with Helm base values + environment overlays
[ ] Do all environments share a single Kustomize base
[ ] Did you enable selfHeal and prune in Argo CD/Flux
[ ] Do you enforce IngressClass and required annotations with Kyverno
[ ] Is TLS managed with cert-manager or encrypted secrets
[ ] Does CI run build/schema/policy validation
[ ] Do you post the Argo CD diff on the PR to review before apply

Closing thoughts

Managing Ingress as code is not merely putting YAML in Git. It is building a coherent system: separating the controller and manifests into two layers, expressing environment differences explicitly with Helm and Kustomize, converging the cluster to Git with Argo CD or Flux, and standing up safety guardrails with Kyverno.

Once this system is in place, the question "who changed the production Ingress, when, and why" can be answered with Git history, and bad changes are blocked by policy gates and drift detection. And since this pattern applies equally to the Ingress API and the successor standard Gateway API, you can carry the version-control framework forward even if you later migrate to the Gateway API.

References