Helm vs Kustomize for GeoNode Deployments
Every team that runs GeoNode on Kubernetes eventually confronts the same fork: manage the manifests for GeoServer, MapProxy, PostGIS, and Celery through Helm’s templating engine, or through Kustomize’s overlay-and-patch model. Choosing badly leaves you with unreadable template soup or a sprawl of copy-pasted overlays, and either way the multi-service stack becomes brittle to change. This guide settles the decision on operational grounds — how each tool parameterizes a release, rolls back, handles secrets, and fits a GitOps pipeline — rather than on syntax preference.
This guide sits within the Infrastructure Orchestration & Configuration Management practice and is meant to be read before you commit a deployment topology to code, because the packaging tool you pick constrains how you will version, promote, and audit every service in the portal for years afterward. The stack in question is not a single container: it is a coordinated set of GeoNode (Django + Gunicorn), a Celery worker pool and beat scheduler, GeoServer as the OGC engine, MapProxy as the tile cache, and a PostGIS backing store that must be treated as stateful. The packaging model has to express that whole graph without hiding its wiring.
The two tools answer the same question — “how do I produce environment-specific manifests from a shared base?” — with opposite mechanics, which the matrix below makes concrete.
Where each tool sits in the deployment pipeline
Helm is a package manager: it renders a directory of Go templates against a merged values tree, then installs the result as a named release that the Helm client tracks in cluster storage. That release object is the conceptual center of Helm — it is what makes helm rollback, helm history, and helm upgrade --atomic possible. For a GeoNode stack you either author one umbrella chart with GeoServer, MapProxy, PostGIS, and Celery as subcharts, or you compose upstream charts (Bitnami PostgreSQL, for instance) as dependencies pinned in Chart.yaml. The whole graph then upgrades and rolls back as a single transactional unit.
Kustomize is a template-free customization engine, built into kubectl as kubectl apply -k. It never invents its own syntax: every input is a valid Kubernetes manifest, and environments differ only by overlays that patch a shared base. There is no release, no revision history, and no rollback command — Kustomize produces a stream of manifests and hands them to whatever applies them, whether that is kubectl, Argo CD, or Flux. Its promise is that what you read in Git is what Kubernetes receives, with no rendering surprises, because there is no templating language between the two.
The distinction that matters operationally is lifecycle ownership. Helm owns the release lifecycle; Kustomize deliberately owns nothing beyond manifest generation and delegates state to the apply layer. That single difference propagates into how you roll back, how you detect drift, and how much of your platform’s safety depends on the tool versus on the GitOps controller wrapped around it. Keeping that rendered output identical across staging and production is the same discipline covered in Environment Parity in Geospatial CI Pipelines.
Parameterization: values versus patches
The clearest way to feel the difference is to parameterize one field — GeoNode replica count and its PostGIS hostname — in both models. With Helm, the manifest is a template and the environment-specific number lives in a values file that the engine substitutes at render time. Because that template contains Go-template directives, it is not valid YAML on its own and must be shown as a template, not a parsed manifest:
# templates/geonode-deployment.yaml — rendered by Helm at install time
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-geonode
spec:
replicas: {{ .Values.geonode.replicas }}
template:
spec:
containers:
- name: geonode
image: "geonode/geonode:{{ .Values.geonode.tag }}"
env:
- name: DATABASE_URL
value: "postgis://geonode@{{ .Values.postgis.host }}:5432/geonode"
The value that fills those slots is ordinary, parseable YAML — this is the file you edit per environment, and its structure is the subject of the companion guide Writing a Helm Values Overlay for a GeoNode Staging Environment:
# values.staging.yaml — the environment-specific inputs Helm merges over defaults
geonode:
replicas: 2
tag: "4.4.2"
postgis:
host: postgis-staging.geoportal.svc
Kustomize inverts the flow. The base is a complete, working manifest — no placeholders — and each environment patches only the fields it needs to change. Nothing is templated; a reader sees real Kubernetes objects at every layer:
# base/geonode-deployment.yaml — a complete, valid manifest with sane defaults
apiVersion: apps/v1
kind: Deployment
metadata:
name: geonode
spec:
replicas: 1
template:
spec:
containers:
- name: geonode
image: geonode/geonode:4.4.2
# overlays/staging/kustomization.yaml — patch only what differs from base
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- target:
kind: Deployment
name: geonode
patch: |-
- op: replace
path: /spec/replicas
value: 2
The trade-off is now visible. Helm centralizes every knob in a values tree, which is powerful for charts with dozens of parameters but pushes complex logic into template conditionals that are hard to review. Kustomize keeps each layer a literal manifest, which is trivially auditable, but expressing a value that must appear in five places (a shared image tag, say) means either a replacements block or repeating the patch. Neither is wrong; they optimize for different failure modes — Helm for breadth of configuration, Kustomize for transparency of output.
Release lifecycle, rollback, and drift
For a stateful stack, rollback behavior is not a convenience — it is a safety property, and the two tools diverge sharply here. Helm records each helm upgrade as a numbered revision, so recovering from a bad GeoServer config change is one command:
helm history geonode -n geoportal
helm rollback geonode 7 -n geoportal --wait --timeout 5m
That rollback re-applies the manifests from revision 7 exactly as they were rendered, which is genuinely useful — but with a hard caveat for this stack: Helm will happily roll a Deployment back, and it will not undo a PostGIS schema migration or reverse a PersistentVolumeClaim change. Rollback restores manifests, never data. Any team relying on helm rollback for a database tier must pair it with the migration and volume discipline described in Kubernetes StatefulSets for PostGIS Databases, or an “instant rollback” will quietly point a new pod at an incompatible schema.
Kustomize has no rollback verb at all. Reverting means reverting the Git commit and re-applying — which, under a GitOps controller, is exactly the intended model: Git is the single source of truth and the live cluster is reconciled toward it. Drift detection follows the same split. Helm surfaces drift through the helm diff plugin, comparing the last release against a proposed upgrade; Kustomize surfaces it by rendering and diffing against the live cluster:
# Kustomize drift check: render the overlay and diff it against live state
kustomize build overlays/staging | kubectl diff -f -
Both approaches belong in a pipeline that fails on unexpected divergence. The GitOps discipline of never letting runtime configuration wander from the version-controlled declaration is the same one applied when Syncing GeoNode Environments with Terraform, where the infrastructure layer and the workload layer must both reconcile to code.
Secrets, hooks, and CRDs
Secrets handling is where Helm’s flexibility becomes a liability if unmanaged. A naive chart interpolates database passwords straight into rendered manifests, which then land in release history in plaintext. The correct pattern is to reference a secret that already exists in the Kubernetes cluster — created by an external controller such as External Secrets or Sealed Secrets — rather than templating the credential itself:
# values fragment: point GeoNode at a pre-provisioned Secret, never inline the value
postgis:
existingSecret: geonode-db-credentials
secretKeys:
passwordKey: postgres-password
Kustomize ships a secretGenerator that builds a Secret from files or literals and appends a content hash to its name so that changing a credential forces a rolling restart. That is convenient, but the generated Secret is still base64, not encrypted, so it must never be committed raw — it has to be sealed or sourced from a manager before it reaches Git. On this axis the tools are roughly even: both can do the right thing, and both make the wrong thing easy.
Lifecycle hooks are a genuine Helm-only capability. GeoNode needs an ordered pre-upgrade step — run manage.py migrate and collectstatic before the new web pods roll — and Helm expresses that with a hook-annotated Job:
# a migration Job gated to run before the app pods upgrade
apiVersion: batch/v1
kind: Job
metadata:
name: geonode-migrate
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: geonode/geonode:4.4.2
command: ["python", "manage.py", "migrate", "--noinput"]
Kustomize has no equivalent — it cannot order a migration before an apply, so under Kustomize that sequencing moves out to the GitOps controller (Argo CD sync waves, Flux dependencies) or to a separate pipeline stage. Likewise, installing CRDs — for a cert-manager or an operator the portal depends on — is a first-class Helm concern via the crds/ directory, whereas Kustomize simply treats a CRD as one more resource with no install ordering guarantees. If your stack leans on operators and ordered upgrades, that gap is decisive.
GitOps fit and the hybrid post-render pattern
Both tools are first-class citizens in Argo CD and Flux, so GitOps is not a tiebreaker on availability — it is a tiebreaker on style. Argo CD can point an Application at a Helm chart and manage the release itself, or it can render Kustomize overlays and reconcile the output; Flux offers HelmRelease and Kustomization custom resources that do the same. The practical question is whether you want the controller to own the release (Helm) or to own the reconciliation of literal manifests (Kustomize).
You do not have to choose absolutely, and many mature GeoNode platforms do not. The hybrid pattern renders the Helm chart first, then post-processes the output with Kustomize — capturing Helm’s rich upstream charts and hook support while keeping a final, transparent patch layer under local control:
# kustomization.yaml — post-render a Helm chart, then patch its output
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
helmCharts:
- name: geonode
repo: https://example-charts.internal
version: 4.4.2
releaseName: geonode
valuesFile: values.staging.yaml
patches:
- target:
kind: Deployment
name: geonode-geonode
patch: |-
- op: add
path: /spec/template/metadata/annotations/portal.local~1patched
value: "true"
This works either through Kustomize’s native Helm chart inflation (shown above) or through Helm’s own --post-renderer flag pointing at a Kustomize invocation. The upstream mechanics of both flows are documented in the Helm documentation and the Kustomize reference, and either is a defensible foundation for the portal.
Operational troubleshooting matrix
Most packaging failures announce themselves clearly once you know which tool owns the symptom. Diagnose against the matrix below before rerendering blindly.
| Symptom | Likely cause | Fix |
|---|---|---|
helm upgrade succeeds but pods run old config |
Rendered template unchanged; only a runtime env value moved | Confirm the value flows into a manifest field, not just values.yaml; check helm get manifest |
| Rendered YAML is invalid after a values change | Go-template whitespace or a missing quote on a numeric-looking string |
Run helm template and lint; wrap ambiguous values with `{{ .Value |
| Kustomize patch silently does nothing | target selector does not match kind/name/namespace |
Verify with kustomize build and tighten the patch target; names change after prefixes/suffixes |
| Secret ends up in Git or release history | Credential templated inline instead of referenced | Switch to existingSecret (Helm) or sealed secretGenerator (Kustomize) |
| Migration runs after web pods, causing 500s | No ordering guarantee under Kustomize | Move migration to a Helm pre-upgrade hook or an Argo CD pre-sync wave |
helm rollback restores pods but app still errors |
Rollback reverted manifests, not the PostGIS schema | Pair rollback with backward-compatible migrations; never roll back schema via Helm |
| Image tag must change in five manifests, only one updates | Value repeated instead of centralized | Use Helm values or a Kustomize images/replacements transformer |
| Argo CD shows perpetual OutOfSync on generated names | Hash-suffixed Secret/ConfigMap names differ each render | Pin generator behavior or disable name hashing for tracked resources |
Two habits keep this table actionable. First, always inspect the rendered output — helm template or kustomize build — in review, because both tools fail most often at the gap between what an author intended and what Kubernetes actually receives. Second, gate that rendered output through a schema validator such as kubeconform in CI, so a broken manifest is caught before a controller ever tries to apply it against the live portal.
Choosing a model
Reach for Helm when the GeoNode stack depends on packaged upstream charts, ordered upgrades, and lifecycle hooks — the migration-before-rollout sequencing and CRD installation are exactly what a template-free tool cannot express, and rebuilding them by hand is more work than learning the templating language. Reach for Kustomize when transparency and reviewability dominate: a small platform team that wants every layer to be a literal, diffable manifest with no rendering engine in the loop will move faster and audit more easily with overlays. For most agency and enterprise portals the durable answer is the hybrid — Helm to assemble and version the stack, a thin Kustomize post-render layer for the last-mile, environment-specific patches — which captures the strengths of both without forcing the team to relitigate the decision every time a new service joins the portal.
Related
- Writing a Helm Values Overlay for a GeoNode Staging Environment — the hands-on companion that structures the values files this decision depends on.
- Environment Parity in Geospatial CI Pipelines — keeping rendered manifests identical from staging to production.
- Syncing GeoNode Environments with Terraform — reconciling the infrastructure layer beneath these workloads to code.
- Kubernetes StatefulSets for PostGIS Databases — why rollback of the database tier needs migration and volume discipline, not just a Helm revision.
Up one level: Infrastructure Orchestration & Configuration Management.