Kubernetes Packaging and Deployment: Kustomize, Helm, and GitOps

✍️

This is the second part of the Kubernetes primer series. The first part covered the core building blocks — Pods, Deployments, Services, Secrets, PVCs, and Helm basics. This part goes deeper into the two dominant approaches to packaging Kubernetes manifests, and then introduces GitOps as an alternative to running deploy scripts manually.


The manifest problem

A real Kubernetes application needs dozens of YAML files: Deployments, Services, ConfigMaps, Secrets, Ingress rules, HorizontalPodAutoscalers, PodDisruptionBudgets. Writing them by hand is feasible once, but the moment you need the same app running in three environments — local, staging, production — you face a choice:

  • Copy the files for each environment and keep them in sync manually (fragile)
  • Use a tool that handles the variation for you

Two tools dominate: Kustomize and Helm. They solve the same problem differently, and many projects use both — Helm for third-party software, Kustomize for their own app.


Kustomize — layered YAML patches

Kustomize ships with kubectl (no install needed) and works with plain YAML. The idea is a base + overlays structure:

manifests/
  base/
    deployment.yaml      # canonical deployment
    service.yaml
    kustomization.yaml   # lists the resources
  overlays/
    dev/
      kustomization.yaml # patches for dev
      patch-replicas.yaml
    prod/
      kustomization.yaml # patches for prod
      patch-replicas.yaml
      patch-resources.yaml

The base defines the resource once. Each overlay patches only what differs. A typical patch looks like:

# overlays/prod/patch-replicas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
spec:
  replicas: 4       # override base value of 2

To deploy the prod overlay:

kubectl apply -k overlays/prod/

Kustomize merges the base YAML with all patches before sending anything to the API server. You always see plain, readable YAML — there is no templating language to learn, and the output is predictable.

Variable substitution

For values that vary by environment (hostnames, image tags, resource sizes), Kustomize offers substituteFrom: it reads variables from a ConfigMap or Secret and injects them into the manifests at apply time:

# kustomization.yaml
configurations:
  - var-references.yaml
vars:
  - name: APP_DOMAIN
    objref:
      kind: ConfigMap
      name: project-values
      apiVersion: v1
    fieldref:
      fieldpath: data.domain

This is less flexible than Helm’s full templating but keeps the YAML closer to what Kubernetes actually receives.

What Kustomize does not do

Kustomize has no concept of a release, no revision history, and no built-in rollback. If you apply a broken overlay, you must fix it and reapply, or manually apply a previous version. For the same reason, there is no --atomic safety net — if a deployment fails mid-rollout, you notice from kubectl output, not from the packaging tool.


Helm — templated packages

Helm wraps Kubernetes YAML in a full templating engine (Go templates) and adds lifecycle management on top. A chart is a directory:

doc-router/
  Chart.yaml          # name, version, appVersion
  values.yaml         # default values
  templates/
    deployment.yaml   # Go template
    service.yaml
    ingress.yaml
    _helpers.tpl      # reusable template fragments

A template looks like:

# templates/deployment.yaml
spec:
  replicas: 
  template:
    spec:
      containers:
        - name: backend
          image: ":"
          resources:
            requests:
              cpu: 

To install with custom values:

helm upgrade --install doc-router ./doc-router \
  --set replicaCount=4 \
  --set image.tag=v1.2.3

Or via an override file:

helm upgrade --install doc-router ./doc-router -f values-prod.yaml

Release history and rollback

Helm records every install and upgrade as a numbered revision in the cluster. You can inspect history and roll back:

helm history doc-router -n doc-router
helm rollback doc-router 2 -n doc-router   # back to revision 2

With --atomic, a failed upgrade automatically triggers a rollback — the old version keeps running uninterrupted.

Publishing charts as OCI artifacts

A packaged chart can be pushed to any OCI-compatible registry (ghcr.io, ECR, Docker Hub) and pulled from anywhere:

helm push doc-router-0.3.7.tgz oci://ghcr.io/analytiq-hub
helm upgrade --install doc-router oci://ghcr.io/analytiq-hub/doc-router --version 0.3.7

This means a customer cluster can install your app with a single command, pulling both the chart and images from the same registry, with no Git access required.


Kustomize vs Helm — when to use each

  Kustomize Helm
Learning curve Low — just YAML Higher — Go templates + chart structure
Flexibility Patches and substitutions Full templating, conditionals, loops
Release history None Built-in, per-revision
Rollback Manual helm rollback
Failure safety None --atomic auto-rollback
Publishing OCI artifact via Flux helm push to any OCI registry
Best for Your own first-party manifests Distributable packages, third-party software

In practice many projects use both: Helm for installing third-party dependencies (ingress-nginx, cert-manager, MongoDB operator), and Kustomize for their own application manifests. The two are compatible — a Kustomize overlay can reference a Helm chart as a generator.


GitOps — the cluster manages itself

Both Kustomize and Helm, as described so far, are imperative: a human (or a CI job) runs a command that pushes changes into the cluster. GitOps flips this model.

In GitOps, the desired cluster state is declared in a Git repository (or an OCI artifact registry). A controller running inside the cluster continuously watches that source and reconciles actual state to match it. No one runs helm upgrade — the cluster pulls its own updates.

Developer pushes to Git / CI pushes OCI artifact
         ↓
  Source of truth updated
         ↓
  In-cluster controller detects drift
         ↓
  Controller applies the diff
         ↓
  Cluster matches desired state

The key property: the cluster self-heals. If someone manually deletes a Deployment or edits a ConfigMap, the controller notices the drift and reverts it within seconds. The Git repo (or OCI artifact) is always the authoritative source.


Flux — a GitOps controller

Flux is one of the two dominant GitOps controllers (the other is Argo CD). It runs as a set of controllers in the cluster and watches sources:

Sources

Flux can watch:

  • Git repositories — on every push, Flux reconciles the cluster
  • OCI artifact registries — on every flux push artifact, Flux pulls and applies
  • Helm repositories — for managing Helm releases declaratively

Core resources

GitRepository / OCIRepository — defines where Flux watches:

apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: OCIRepository
metadata:
  name: my-app
  namespace: flux-system
spec:
  interval: 1m
  url: oci://123456789.dkr.ecr.us-east-1.amazonaws.com/my-app-manifests
  ref:
    tag: latest

Kustomization — tells Flux what to apply from the source:

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: my-app
  namespace: flux-system
spec:
  interval: 5m
  sourceRef:
    kind: OCIRepository
    name: my-app
  path: ./manifests/kubernetes/overlays/prod
  prune: true      # delete resources removed from source
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: backend
      namespace: my-app

HelmRelease — manages a Helm release declaratively:

apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
  name: ingress-nginx
  namespace: flux-system
spec:
  interval: 1h
  chart:
    spec:
      chart: ingress-nginx
      version: "4.11.3"
      sourceRef:
        kind: HelmRepository
        name: ingress-nginx
  values:
    controller:
      replicaCount: 2

CI/CD with Flux

A typical Flux-based pipeline looks like:

1. Developer opens a PR
2. CI runs tests
3. PR merged to main
4. CI builds Docker image → pushes to ECR
5. CI packages Kustomize manifests as OCI artifact → flux push artifact → ECR
6. Flux detects new artifact version
7. Flux applies manifests to cluster
8. Cluster rolls out new Deployment

Steps 6–8 happen automatically, inside the cluster, with no deploy script and no human intervention.

Flux vs running deploy scripts

  Shell script (helm upgrade) Flux GitOps
Who initiates deploy Human or CI job Cluster controller
Drift detection None — manual kubectl needed Continuous — auto-reverts
Audit trail CI logs Git history + Flux events
Rollback helm rollback Revert commit, Flux reconciles
Complexity Low — just a shell script Higher — Flux controllers + CRDs
Air-gapped / on-prem Simple Requires Flux + registry access

GitOps is the right choice for teams with multiple people deploying to shared clusters, or for production environments where drift must be detected and prevented. For a small team or a self-hosted product where simplicity matters, shell scripts with helm upgrade --install are easier to understand, debug, and hand off to a customer.


Summary

Tool Role Key strength
Kustomize Overlay-based YAML patching Plain YAML, no templates, built into kubectl
Helm Templated package manager Release history, rollback, publishable charts
Flux GitOps controller Self-healing cluster, drift detection, no manual deploys
Argo CD GitOps controller (alternative to Flux) Web UI, application health visualisation

A mature production setup typically uses all three: Kustomize or Helm for defining manifests, Flux or Argo CD for reconciling them, and a CI pipeline that produces the artifacts both consume.

Next: Deploying Doc Router on Kubernetes walks through a real application deployment (Helm chart, workers, CI/CD, EKS and Digital Ocean). If you need in-cluster MongoDB with vector search, see Self-Hosted MongoDB on Kubernetes with Atlas Search.


Andrei Radulescu-Banu is the founder of DocRouter.AI (document processing with LLMs) and SigAgent.AI (Claude Agent monitoring). His company AnalytiqHub.com provides consulting services for cloud and AI engineering.