Helm Charts Generated from Nix
Helm Charts Generated from Nix
Helm is the cluster’s package manager whether you like it or not. The boring default is: pin the chart and the values in Nix (vendored Chart.yaml tarball + hash), render with helm template, and never helm install from floating latest.
Mental model
A chart is a tarball plus values. Unpinned helm repo add && helm upgrade is npm install on the control plane.
| Pin | How |
|---|---|
| Chart version | fetchurl of the .tgz + hash (FOD) |
| Values | Nix attrset → YAML |
| Image inside values | Same digest as .#image |
If the app is yours, prefer raw manifests. Helm when the vendor only ships a chart (ingress-nginx, cert-manager). helm repo update in CI is how Monday’s job is not Friday’s chart.
Worked examples
Case 1: Vendor a chart tarball
helm pull ingress-nginx --version 4.11.0 --repo https://kubernetes.github.io/ingress-nginx
nix hash path ingress-nginx-4.11.0.tgzSave as chart.nix:
# chart.nix
{ pkgs ? import <nixpkgs> { } }:
pkgs.fetchurl {
url = "https://github.com/kubernetes/ingress-nginx/releases/download/helm-chart-4.11.0/ingress-nginx-4.11.0.tgz";
hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
}nix-build chart.nixProbe the hash (got:). That tarball is now as pinned as hello. Commit the hash, not a live repo index.
Case 2: Render values from Nix
Save as values.nix:
# values.nix
{
controller = {
replicaCount = 2;
image.tag = "v1.11.2";
service.type = "ClusterIP";
};
}Helm wants YAML. Do not hope JSON-as-values is always accepted — write YAML from Nix:
# values-file.nix
{ pkgs, ... }:
pkgs.writers.writeYAML "values.yaml" (import ./values.nix)nix-build -E 'with import <nixpkgs> {}; callPackage ./values-file.nix {}'
helm template desk-ing ./ingress-nginx-4.11.0.tgz \
--namespace desk \
--include-crds \
--kube-version 1.31.0 \
-f result \
> generated/ingress.yaml--include-crds is how cert-manager actually installs. Without it you apply Deployments against CRDs the cluster does not have. --kube-version pins the Capabilities the chart sees so CI and the cluster do not render different apiVersions. --namespace must match GitOps targetNamespace.
Commit generated/ingress.yaml if Argo cannot run Helm+Nix. The image tag must match the digest your flake built, not latest.
Case 3: Wrapper script in a flake
Save as flake.nix:
# flake.nix
{
description = "Desk helm render";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
chart = pkgs.fetchurl {
url = "https://github.com/kubernetes/ingress-nginx/releases/download/helm-chart-4.11.0/ingress-nginx-4.11.0.tgz";
hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
in
{
packages.x86_64-linux.render-ing = pkgs.writeShellApplication {
name = "render-ing";
runtimeInputs = [ pkgs.kubernetes-helm ];
text = ''
helm template desk-ing ${chart} \
--namespace desk \
--include-crds \
--kube-version 1.31.0 \
-f ${./values.yaml}
'';
};
};
}values.yaml can be builtins.toJSON written by a derivation so it is not hand-edited. The chart path is a store path — no helm repo add.
Case 4: Apply the bits you reviewed
nix run .#render-ing | kubectl apply --server-side -f -or helm upgrade --install desk-ing ./ingress-nginx-4.11.0.tgz -f values.yaml from the vendored tgz, not from a live repo index. GitOps (Argo/Flux) applies the rendered YAML; it does not call helm repo update.
Case 5: Diff in CI
nix run .#render-ing > /tmp/new.yaml
git diff --no-index generated/ingress.yaml /tmp/new.yamlIf the diff is unexpected (chart author pushed a hook), you see it before the cluster does. Non-zero diff on an unreviewed chart bump is a failed PR, not an automatic apply.
Pin the image inside values to a digest, same rule as raw manifests:
# values.nix fragment
{
controller.image = {
registry = "registry.k8s.io";
image = "ingress-nginx/controller";
tag = "v1.11.2";
digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
};
}The trap
The trap is helm repo update in CI with no version. Monday’s job is not Friday’s chart. Version + hash, like any FOD. The other trap is pinning the chart and leaving image.tag: latest in values.
A third: helm template without --include-crds for a CRD chart. A fourth: omitting --kube-version so a laptop on Helm 3.16 and CI on 3.17 emit different YAML for the same chart.
The boring rule
- Helm for vendor charts. Nix manifests for your apps.
- Chart tarball in the store (
fetchurl+ hash) or git-vendor. - Values from Nix (
writers.writeYAML). Images from the same flake (digest). helm template --include-crds --kube-version … --namespace …. Render in CI; apply the bits you reviewed.- No
latestchart, nolatestimage. Nohelm repo update.
Try this
helm pulla tiny chart,nix hash paththe tgz, put it infetchurl.- Change
replicaCount, re-render,diff. - Break the hash by one character and read the FOD error.
- Search CI for
helm repo updateand delete it. - Render twice, once with
--include-crdsand once without;diff— know what you would have skipped.