Helm Charts Generated from Nix

Updated

September 12, 2026

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.tgz

Save 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.nix

Probe 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.yaml

If 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 latest chart, no latest image. No helm repo update.

Try this

  1. helm pull a tiny chart, nix hash path the tgz, put it in fetchurl.
  2. Change replicaCount, re-render, diff.
  3. Break the hash by one character and read the FOD error.
  4. Search CI for helm repo update and delete it.
  5. Render twice, once with --include-crds and once without; diff — know what you would have skipped.