ADR 008: Template-Based Nix Expression Generation¶
Status: Final Type: Feature Created: 2024-07-17 Supersedes: ADR 007 Related-ADRs: 006, 009, 020, 022, 030, 031, 035, 036
Context¶
What We Have¶
The NixBuilder plugin (ADR 006) builds applications from hand-crafted hop3.nix files. This works well but requires the developer to write a Nix derivation — a significant barrier given Nix's well-documented learning curve (see Fourné et al., "It's like flossing your teeth: On the importance and challenges of reproducible builds," IEEE S&P 2023). The production fleet of hand-crafted hop3.nix files demonstrates the model, but writing each one takes meaningful effort and Nix expertise.
The Problem¶
The current situation creates a two-tier experience:
- With
hop3.nix: Content-addressed dependency graph, bandwidth-efficient updates, and a path toward reproducible builds (see "Reproducibility Levels" below for the precise caveats). But requires Nix expertise. - Without
hop3.nix: Fast native builds via the LocalBuilder, but no reproducibility guarantees and no content-addressed closure. This is what most developers will use.
We want to close this gap: give developers the structural benefits of Nix (content-addressed closures, atomic upgrades, minimal update deltas) without requiring them to learn the Nix expression language.
Reproducibility Levels¶
The term "reproducible builds" is often used loosely. In Hop3's context, the actual guarantee depends on the build type:
| Build type | Hermetic sandbox | Reproducible | Auditable | Example |
|---|---|---|---|---|
| Pure Nix (pinned nixpkgs, all deps from Nix store) | Yes | Yes (modulo build tool quirks like timestamps) | Yes (full closure graph to source) | Go apps via buildGoModule with vendored deps |
Nix with __noChroot (pip, composer install at build time) |
No — network access during build | No — depends on PyPI/Packagist state at build time | Partial — closure exists but sources aren't pinned | BookStack (composer), Isso (pip) |
Nix with fetchurl pre-built binary (sha256-pinned) |
Yes (fixed hash) | Yes (same bytes every time, as long as URL is live) | No — can't rebuild from source; trusting upstream binary | Gitea, Miniflux, Grafana |
| Native builder (no Nix) | No | No | No | Most apps today |
Key nuances:
-
__noChroot = truebreaks hermeticity. Ourpython-venvandphp-app(with composer) templates use this to allow network access during build. Two builds on different days can fetch different dependency versions from PyPI/Packagist. This is no better thanpip installon the host — the Nix packaging is structural (content-addressed output, atomic rollback) but not hermetic. -
Pre-built binaries are reproducible but not auditable. Fetching a Gitea binary with a pinned sha256 guarantees you always get the same bytes. But you can't verify what those bytes contain — you're trusting the upstream release process. This is the same trust model as pulling a Docker image from Docker Hub, just with a content hash instead of a mutable tag.
-
Source availability is not guaranteed. Even pure Nix builds depend on upstream sources being available. If PyPI deletes a package or a GitHub release disappears, the build fails. Nix doesn't mirror sources by default (though the Nix binary cache and NixOS Hydra CI provide some resilience).
-
True hermeticity requires pure Nix builds with vendored or Nix-packaged dependencies. This is achievable (e.g.,
buildGoModulewith vendored deps, or all Python packages from nixpkgs rather than pip). But it's significantly more work to set up and maintain, which is why our current templates take the pragmatic__noChrootshortcut.
What Nix DOES guarantee in all cases:
- Content-addressed outputs. Every built package has a unique store path derived from all its inputs. If the inputs change, the output path changes. If inputs are identical, the path is identical — enabling cache reuse across machines.
- Atomic upgrades and rollbacks. Deployments are a symlink switch. Rolling back is instant and side-effect-free.
- Minimal update deltas. When updating an app, only changed store paths need to be transferred (Proposition 1 from the paper). This holds even for
__noChrootbuilds — the delta is still smaller than a Docker image layer replacement. - Explicit dependency graph. The full closure is inspectable via
nix-store -qR. No hidden dependencies, unlike Docker's opaque layers or pip's global site-packages.
Implication for the template approach: The generated hop3.nix expressions provide the structural benefits of Nix (content-addressing, atomic upgrades, closure inspection) but do NOT automatically provide full hermeticity for ecosystems that use __noChroot. Moving from __noChroot pip/composer to fully-pinned Nix-native dependency resolution is a future evolution, not a template concern. The templates faithfully generate what a developer would write by hand.
Why Not Ecosystem Tools¶
An obvious alternative is to use ecosystem-specific Nix tools: poetry2nix, dream2nix, node2nix, buildGoModule, crane, etc. This is the wrong approach for three concrete reasons:
-
The hand-written
hop3.nixfiles don't use them. The existing files inapps/real-apps-nix/contain no references topoetry2nix,dream2nix,node2nix, orbuildGoModule. The Ruby apps usebundlerEnv(a built-in nixpkgs function), and everything else uses plainstdenv.mkDerivationwithfetchurland a custominstallPhase. -
The ecosystem tools don't cover the stack.
poetry2nixhandles Python-with-poetry only.dream2nixis in flux.node2nixis effectively deprecated. There's no equivalent for PHP — the largest ecosystem in the fleet — nor for Java or pre-built binaries. Building on tools that don't cover half the fleet is a losing proposition. -
The manual conversion pattern is highly regular. The hand-written files are roughly 60% boilerplate and 40% per-app logic. The per-app logic is expressible declaratively (paths, env vars, config file contents, exec commands). A template system is a better fit than composing external tools.
Decision¶
When the operator selects the Nix builder and no hop3.nix file exists, Hop3 will generate one at build time from a declarative template specification stored in hop3.toml. The generated Nix expression is equivalent to what a developer would write by hand following the patterns observed in apps/real-apps-nix/.
The generator is plugin-based: each template is a registered plugin implementing a simple Template protocol. Adding a new ecosystem means adding a new template — not modifying existing ones.
Fallback Hierarchy¶
hop3.nix exists in source?
→ Yes: Use it directly (ADR 006)
→ No: hop3.toml has [nix] section with template name?
→ Yes: Generate hop3.nix from template at build time
→ No: Fall back to LocalBuilder with native toolchains
NixBuilder.accept() builds an app when it declares [nix].template. A hand-written hop3.nix and a [nix].template are mutually exclusive inputs: when both are present the builder refuses rather than silently choosing one, since the two sources can diverge.
Ejection¶
When a generated template cannot express an app's needs, the developer runs hop3 nix eject <app> to materialize the generated hop3.nix as a real file in the source tree. After ejection, the committed hop3.nix takes precedence and can be customized freely. This mirrors Create React App's eject pattern: auto-generation is progressive disclosure, not lock-in.
Templates¶
Each template captures one recurring packaging pattern observed in apps/real-apps-nix/. The set spans the production stacks:
| Template | Apps covered | Key patterns |
|---|---|---|
prebuilt-binary |
miniflux, gitea | Single pre-compiled binary, exec args, INI config generation, runtime secret generation |
prebuilt-archive |
focalboard, grafana, mattermost, vikunja | tar.gz/zip archives, file mappings, store-to-cwd symlink loops (mattermost), JSON/YAML/INI configs |
php-app |
adminer, bookstack, dolibarr, easy-appointments, invoice-ninja, kanboard, limesurvey, matomo, nextcloud, wordpress | Single file (adminer), composer build, Laravel artisan serve, custom web root (dolibarr), zip with wrapper dir (limesurvey), tar.bz2 (nextcloud), --ignore-platform-reqs (invoice-ninja), extra nativeBuildInputs (nodejs for invoice-ninja) |
node-prebuilt |
wiki-js | Tarball without top-level dir, read-only store symlink loop, YAML config |
java-war |
jenkins | Single WAR file, JDK runtime, $JAVA_OPTS |
python-venv |
isso | __noChroot, pip install inside nix build, runtime INI config |
nixpkgs-wrapper |
radicale | Wraps existing nixpkgs package (no source fetch, no build) |
ruby-bundler |
sinatra-hello, rack-hello | bundlerEnv from a Gemfile, rack-based serving |
Design Overview¶
AppSpec Data Model¶
A declarative spec with fields grouped by concern:
- Identity:
pname,version,description,template - Source:
url,sha256,archive(None/tar-gz/tar-bz2/tar-xz/zip),executable - Extraction:
source_root(for archives with wrapper dirs),strip_components - Runtime setup:
runtime_package(e.g.,jdk17,nodejs_22),php_version,php_extensions,nixpkgs_package - Build phase:
needs_composer,composer_extra_flags,extra_native_build_inputs,pip_packages - Serving:
exec_target,exec_args,serve_mode(builtin/artisan/custom),web_root - Wrapper script:
local_vars,env_exports,conditional_env_exports,pre_exec_commands,config_files - Runtime metadata:
runtime_env,extra_paths
Placeholder Pattern¶
Each template uses sed-replaced placeholders for Nix variables that need to be resolved to absolute store paths at build time:
| Placeholder | Template | Resolves to |
|---|---|---|
BINDIR |
all | $out/bin |
SHAREDIR |
prebuilt-archive | $out/share/<pname> |
APPDIR |
php-app, node-prebuilt | $out/app |
PHPBIN |
php-app | ${php}/bin (the withExtensions php) |
NODEBIN |
node-prebuilt | ${nodejs}/bin |
JAVABIN, WARPATH |
java-war | ${jdk}/bin, $out/app/<file>.war |
VENVBIN |
python-venv | $out/venv/bin |
PKGBIN |
nixpkgs-wrapper | ${package}/bin |
The placeholder pattern is simpler than trying to interleave Nix interpolation with shell escaping inside a multi-line Nix string. Nix evaluates the store paths first; sed then replaces the placeholders in the wrapper script at install time.
Nix Escaping¶
Inside a Nix ''...'' multi-line string, only ${VAR} needs escaping (becomes ''${VAR}). Bare $VAR, $(cmd), and $PWD pass through literally. A small nix_escape() regex function handles all cases.
Consequences¶
Benefits¶
- Lowers Nix barrier to near-zero. Operators describe their app declaratively in
hop3.toml; the generator handles everything else. - Progressive disclosure via eject. Developers can drop to hand-crafted
hop3.nixwhen needed without changing their workflow. - Same BuildArtifact output. The rest of the pipeline (deployer, proxy, etc.) is unchanged. The generator is purely a build-time source transformation.
- Extensible. New templates are pure plugins — third parties can publish ecosystem-specific templates as separate packages.
- Validated end-to-end. Templates are exercised against real apps that build on a real system via
nix-build, not just in theory.
Drawbacks¶
- Per-template maintenance. Each template has to be kept in sync with nixpkgs conventions and upstream app changes. This is mitigated by keeping each template small and by the stability of the patterns — there is no moving target like poetry2nix releases to chase.
- Edge cases escape through
nix:eject. Apps that don't fit any template still require hand-crafted files. This is manageable rather than catastrophic: the template set covers the bulk of the fleet, and ejection handles the rest. - Duplication during migration. While migrating, an app can have both a hand-crafted
hop3.nixand a[nix]section inhop3.toml; they are migrated one at a time, verifying each step.
Design Findings¶
Concrete observations that shape the design:
-
Boilerplate dominates but does not exhaust. The bulk of a hand-written file is boilerplate, but a substantial fraction is real per-app logic. Apps like Mattermost (wrapper with symlink loops, JSON config, runtime secret generation) and Invoice Ninja (composer with
--ignore-platform-reqsplus nodejs in build inputs) cannot be fully abstracted. The template system must be parametric enough to accommodate this rather than assuming the per-app remainder is negligible. -
Placeholder sed-replacement beats in-place Nix interpolation. Putting
${nodejs}/bindirectly inside the wrapper heredoc conflicts with shell variable escaping (${PORT:-8080}also needs special handling). Using placeholders (NODEBIN,PHPBIN, etc.) that get sed-replaced after Nix interpolation is simpler and composes cleanly. -
Unquoted heredocs are the right default for runtime config files. Apps need
${PORT}and$(head -c 32 /dev/urandom | base64)to be evaluated at container startup, not at build time. Thecat > config << EOF(unquoted) pattern handles both. -
Some apps always need hand-crafting. Apps with known upstream issues (complex build systems, deprecated dependencies) aren't magically fixed by templates. The template approach scales the easy cases so developers spend hand-crafting effort only where it matters.
-
Validating with actual
nix-buildcatches bugs pattern-matching can't. Real builds surface bugs that pass both unit tests andnix-instantiate --parse— exec-line escaping and generated-config indentation being the classes that slip through static checks. End-to-end validation via real builds is therefore part of the design, not an afterthought.
Prior Art¶
- nixpacks (Railway): Validates the template-based approach at production scale. Uses nixpkgs primitives, not dream2nix/poetry2nix, confirming our direction. https://nixpacks.com/
- dream2nix / poetry2nix / node2nix: Ecosystem-specific Nix tools we explicitly don't use. They aim for pure-Nix dependency resolution but each covers only one ecosystem and has known stability issues. Our template approach uses
__noChrootas a pragmatic shortcut — see "Reproducibility Levels" for the trade-off. - Create React App eject: The precedent for the "auto-generate then customize" pattern.
- Heroku buildpacks: Same UX goal (auto-detect and build without user config), different technology.
References¶
- E. Dolstra, "The Purely Functional Software Deployment Model," Ph.D. thesis, Utrecht University, 2006. https://edolstra.github.io/pubs/phd-thesis.pdf — The theoretical foundation for content-addressed package management and the guarantees Nix does (and doesn't) provide.
- M. Fourné, D. Wermke, W. Enck, S. Fahl, and Y. Acar, "It's like flossing your teeth: On the importance and challenges of reproducible builds for software supply chain security," IEEE S&P 2023. https://doi.org/10.1109/SP46215.2023.10179320 — Documents the learning curve barrier as the #1 obstacle to reproducible build adoption.
- C. Lamb and S. Zacchiroli, "Reproducible Builds: Increasing the Integrity of Software Supply Chains," IEEE Software, vol. 39, no. 2, pp. 62–70, 2022. https://doi.org/10.1109/MS.2021.3073045 — Defines what "reproducible builds" means precisely (bit-for-bit identical outputs from identical inputs) vs the weaker guarantees most tools actually provide.
- NixOS Wiki, "Nix Pills," https://nixos.org/guides/nix-pills/ — Practical guide to Nix expression language, relevant for understanding what the templates generate.
- Railway, "How Nixpacks Works," https://nixpacks.com/docs/how-it-works — Documents Railway's template-based approach which we validated independently.
Supersedes: ADR 007: Nix Builders for Existing Packages (Nixpkgs Mode)
Related ADRs: ADR 006: Nix Integration with Hop3, ADR 009: Nix Runtime Integration, ADR 020: Pluggable Architecture for Core Deployment Workflow, ADR 022: Build and Deployment Plugin System, ADR 030: Two-Level Build Architecture, ADR 031: Project Terminology (Ubiquitous Language), ADR 035: Build Artifacts as Runtime Contract, ADR 036: CLI Ergonomics and Command Surface