The Nix Shift: Why We Replaced Helmfile with Pure Functions
The Problem
We ran openDesk Edu β 28 services across 9 K3s nodes β using Helmfile with Go templates. Every deployment came with a familiar dread:
failed to render values file "values-grommunio.yaml.gotmpl":
template: stringTemplate:17: unexpected "\\" in operand
This error blocks all 28 services, not just one. Because Helmfile processes all templates as a single step, a single YAML syntax error anywhere halts the entire cluster update.
The symptoms were always the same:
- Cascading failures β a typo in
values-grommunio.yaml.gotmpltook down the entire deployment, even if only Moodle needed an update. - Opaque error messages β Helmfile swallows the actual context. Instead of "line 12, column 3: undefined variable," we got cryptic Go template stack traces.
- No caching guarantees β
helmfile syncre-renders every template every time, even if nothing changed for a given service. With 28 services, that's ~3 minutes of pure rendering time. - Hard to reproduce β the same commit produced different results on CI than locally, because Helmfile implicitly absorbs environment variables and
.envfiles.
Why Nix?
Nix is purely functional. Every build is deterministic and cached. Instead of imperative templates rendered at runtime, we describe each service as a pure function β input in, manifest out, no side effects.
Before: helmfile sync β helm template β Go templates β YAML β kubectl apply
After: nix build .#service-name β pure Nix β JSON β kubectl apply
The key difference: Nix caches every result. If nothing changed for a service, it's loaded from the Nix store in ~2 seconds β no rendering, no re-computation.
The Architecture
Each service is a Nix function that returns a Kubernetes manifest (as JSON):
# flake.nix (simplified)
{
outputs = { self, nixpkgs, ... }: {
apps.moodle = mkK8sApp {
name = "moodle";
image = "ghcr.io/opendesk-edu/moodle-shib:v1.4.0";
port = 8080;
replicas = 2;
env = {
MOODLE_DB_HOST = "mariadb";
MOODLE_DB_NAME = "moodle";
};
ingress = {
host = "moodle.opendesk-edu.org";
tls = true;
};
};
apps.ilias = mkK8sApp {
name = "ilias";
image = "ghcr.io/opendesk-edu/ilias-shibboleth:9-php8.2-apache";
# ...
};
# 26 more services ...
};
}
The mkK8sApp helper generates a Deployment, a Service, an Ingress, and optional ConfigMaps β all as typed Nix derivations. Errors surface at build time, not at runtime.
The Results
| Metric | Helmfile | Nix |
|---|---|---|
| Full deploy | ~3 min | ~30s (first) / ~2s (cached) |
| Error clarity | "failed to render" | "line 12: undefined variable" |
| Deterministic | No | Yes |
| Services | 28 | 28 |
| Lines per service | ~80 | ~5 |
| Reproducibility | Environment-dependent | Bit-for-bit identical |
| Rollback | Manual (helm rollback) | nix flake lock --revision |
Migration: Step by Step
The migration was incremental β no big bang, but service by service:
- Dual operation β Helmfile and Nix ran in parallel at first. New services were defined directly in Nix; existing ones stayed on Helmfile.
- Parity tests β for each migrated service, we compared the Nix and Helmfile manifests with
diff. Only when the output was identical did we switch the service over. - Flake locking β
flake.lockpins all inputs (nixpkgs version, image digests, config hashes). A rollback is agit revertof the lock file. - CI integration β GitHub Actions builds each service with
nix buildand pushes the JSON manifests.kubectl applyis idempotent and takes seconds.
Lessons Learned
What worked well:
- Incremental migration β no risk to running services
- Nix store as build cache β 90% of services are cached on every deploy
- JSON instead of YAML β no indentation errors, no templating language
What surprised us:
- The Nix learning curve is real, but the surface area we actually need (
mkK8sApp,flake.lock,nix build) is manageable - CI builds got faster, not slower β thanks to caching
- Debugging is more pleasant:
nix buildgives exact errors with line numbers; Helmfile gives Go stack traces
What we'd avoid:
- No
ifconditions in Nix expressions for environment differences β instead, separate flakes per environment (flake.prod.nix,flake.staging.nix) - No inline secrets β secrets stay in Kubernetes Secrets, not in the Nix store
Outlook
Nix has transformed our deployment pipeline from a fragile template chain into a deterministic build pipeline. The 28 services of openDesk Edu can now be rolled out in seconds rather than minutes β and every build is reproducible down to the last byte.
The next step: NixOS as the base image for the services themselves, not just the manifests. Then not only the deployment is deterministic, but the runtime environment too.
openDesk Edu is the education variant of openDesk, extended with 25 services for research and teaching. Charts and community platform are available at opencode.de.