Plans

Plan 0042

VM templates, the systemd unit model

Give default.ix a templates export: each template is a typed function from params to a VM. ix new <template> <JSON> instantiates one imperatively as worker@3, the server records template plus params, and every later ix apply re-renders each recorded instance from the current template. Existence is imperative; definition stays declarative.

Status
Draft
Ambition
6 one new verb, but it introduces converged state the repo does not declare
Impact
7 adding the fourth worker stops meaning editing a file and reviewing a diff
Effort
6 CLI verb, a server-side record, and reconciliation in apply
Risk
6 apply gains the power to re-render machines nobody declared
Maturity
4 the declarative half ships: templates + instances exports, rendered by index.lib.templates, with a worked example and an eval gate. The schema generator ships too (index#4450). ix new, the instance record and reconciliation are unbuilt, and a template params type still cannot be both destructured and schema-reachable
Leverage
7 every template author gets --help and editor completion from annotations they already write
Taste
7 the imperative/declarative split is the whole design, and systemd already solved it
Authors
Claude Opus 4.6 (AI agent) commissioned by andrewgazelka
Created
Updated
Tracking issue
ix#9242
Supersedes
-
Superseded by
-

Summary

Add a templates export to default.ix. A template is a function from a params object to what index.lib.mkVm already returns. ix new worker '{"port":9090}' instantiates one as worker@3, the way systemd names getty@tty3. The server records the instance as template name plus params plus source ref, and every later ix apply re-renders each recorded instance from the current template definition with the recorded params.

That last sentence is the design. Existence is imperative; definition stays declarative. Fix a bug in worker.nix, run ix apply, and all seven workers pick it up while keeping their own ports.

Params are JSON, and typed: the params record carries ordinary .ix type annotations, and ix new worker --help plus a JSON Schema for params.json generate from those same annotations. The generator shipped in index#4450; see Typed params for the two gaps that remain, neither of which is introspection.

Motivation

There is no way to add a fourth worker without editing a file. Today default.ix exports fixed, named VMs, and the only imperative create is ix apply <oci-image-ref> with a pile of flags (--name, --env, --secret-*, --l7-proxy-port) that bypasses the config entirely. The flags are the config, and nothing remembers them as intent. mkFleet is deprecated off the product surface (index#4077, index#4079), so “several similar VMs” means copy-pasting mkVm calls.

The reconciliation hole is the real cost, and fly.io demonstrates it. fly.toml is declarative, fly machine run is imperative, and the two do not know about each other. A machine created by hand is invisible to the next deploy: it drifts, forever, silently. Every system that grows an imperative create path without a record ends up here.

This was designed once already and lost. The full design was reached on 2026-07-23 in a chat session, was never filed and never implemented, and recovering it took a sweep across 42 transcript files. This document is that recovery; the verbatim original is preserved in ix#9242.

Detailed design

The systemd map

systemd got this split right: the unit file is declarative and versioned, but starting an instance of it is an imperative act the system then remembers.

systemdix equivalent
foo.servicenamed VM in default.ix, unchanged
foo@.service templatetemplates.worker, a function in default.ix
systemctl start foo@barix new worker bar
%i specifierinstance, injected into the params object as a real value
%N specifier (the unit’s own name)name, the node name, injected the same way
drop-in foo@bar.service.d/*.confoverride layer on the instance record
systemctl editix config edit worker@3
systemctl cat / systemd-deltaix config show worker@3, with per-key provenance
systemctl enablethe instance record itself
systemd-run transient unitix new --transient
preset filesthe instances block in default.ix
daemon-reloadix apply

Where the analogy breaks, on purpose. A systemd unit is local, free, and stateless to restart. A VM is remote, billed by the hour, and has a disk people care about. Three consequences, each load-bearing:

  1. ix apply may re-converge instances but must never create or destroy an instance it did not declare.
  2. Deleting a template from the repo orphans its instances with a loud warning rather than collecting them. They are billed machines with disks.
  3. The override merge happens at eval time in one place, never as config fragments scattered across guests.

Declaring a template

// default.ix
export default ({ index }) => {
  const group = { ix: { networking: { groups: ["app"] } } };

  const web = index.lib.mkVm({
    name: "web",
    modules: [
      import("./web.nix"),
      group,
      { ix: { networking: { expose: { https: { port: 443 } } } } },
    ],
  });

  return {
    nixosConfigurations: { ...web.nixosConfigurations },

    templates: {
      // `instance` is systemd's %i and `name` is the VM's node name, both
      // injected by ix -- see "Two names for one instance" below for why the
      // template does not build the name itself. The annotation is what
      // `ix new worker --help` and params.json completion are generated from.
      // `u32`, not nixpkgs' `ints.positive`: a qualified type name has no
      // runtime lowering and ix2nix rejects it. The built-ins are int, float,
      // u8/u16/u32/i8/i16/i32, port, path, nonEmptyStr, drv,
      // Record<string, T>, and this module's own `type` aliases.
      worker: ({ instance, name, port = 8080, shards = 1 }: {
        instance: nonEmptyStr;
        name: nonEmptyStr;
        port: port;
        shards: u32;
      }) =>
        index.lib.mkVm({
          name: name,
          modules: [
            import("./worker.nix"),
            group,
            { services: { appWorker: { port, shards } } },
            { ix: { networking: { expose: { http: { port: port } } } } },
          ],
        }),
    },

    // Presets: instances pinned declaratively, created by `ix apply` like a
    // named VM. Same template, no drift.
    instances: {
      "worker@1": { port: 8080 },
    },
  };
};

Why a function and not a data schema with substitution. Kubernetes and fly encode templates as data and bolt a substitution language on top. .ix needs neither: the body computes, branches on params, and composes modules, and the eval machinery that already runs default.ix runs the function. A config with no templates key evaluates exactly as it does today.

Why the listener is a module option and not deployment.l7ProxyPorts. Because that key does nothing on the supported path. lib/image/fleet.nix splits deployment keys by who reads them: l7ProxyPorts is a workflow key, read only by the deprecated ix-fleet plan, while ix apply reads the evaluated system alone. A template parameterising it would compile, apply cleanly, and produce a VM with no proxied port and no warning — the ENG-10846 class. Anything a param must actually reach the machine through belongs in a module.

Two names for one instance

An instance has an instance name, worker@1, and a node name, worker-1. The first is systemd’s spelling: it is what the instances block is keyed on, what ix new worker 1 creates, and what the record is keyed on. The second is the nixosConfigurations key, the guest’s networking.hostName, and the OCI repository the image is pushed to.

They cannot be the same string. nixpkgs types networking.hostName as a DNS label, and mkVm sets it from the VM’s name, so a VM named worker@1 fails its own option type before anything is built:

$ nix eval .#lib --apply 'ix: builtins.unsafeDiscardStringContext 
    (ix.mkVm { name = "worker@1"; modules = []; })
      .nixosConfigurations."worker@1".config.system.build.toplevel.drvPath'
error: A definition for option `networking.hostName' is not of type
       `string matching the pattern ^$|^[[:alnum:]]([[:alnum:]_-]{0,61}[[:alnum:]])?$'.
       Definition values:
       - In `/nix/store/…-source/flake.nix': "worker@1"

$ # the same expression with `worker-1`
"/nix/store/rrzwvw9r…-nixos-system-worker-1-26.11.20260723.e2587ca.drv"

The second reason is independent of nix: in an OCI reference @ introduces a digest, so registry.ix.dev/worker@1:tag is not a repository a manifest can be pushed to.

And - cannot replace @ in the instance name. Template worker-pool with instance 1, and template worker with instance pool-1, would spell one string. The instance name has to be parseable back into its two halves, because that is how a record, a CLI argument and an orphan warning all name a template; the node name does not, because nothing ever parses a hostname. So the separator that joins the instance name must be a character that appears in neither half — the property @ has in systemd’s unit names, and the reason this design borrows that character rather than inventing one.

The template never has to know any of this. index.lib.templates derives the node name and injects it as the name param, so a body is index.lib.mkVm({ name, ... }), and a template that names its VM anything else is refused at the boundary with the reason. index.lib.templates.parseInstanceName and instanceNameOf are the only implementations of the two directions, and nothing else in either repo may respell either separator.

Typed params

A bare function has no declared schema: no ix new worker --help, no early type error, and a misspelled param silently takes the default. systemd does better, which is the objection this section answers.

Nothing unsafe reaches a VM either way, because params flow into mkVm and the module system type-checks every option at eval time. The gap is purely where the error arrives: deep in an eval trace, naming a module option rather than your param, after nix has started.

The params record carries ordinary .ix annotations, and the schema is generated from them. That generator now exists. index#4450 made ix-ty introspectable: an annotation parses once into ix2nix::ty::Ty, plain data, and lowers twice from that one value — src/checker.rs emits the byte-identical __ixTy expression that already checked at eval time, and src/schema.rs emits a draft 2020-12 JSON Schema. Range refinements survive the trip, so port becomes minimum: 0, maximum: 65535 rather than integer. index#4447 is closed.

That buys, from a declaration the template author was going to write anyway:

  • editor completion and validation of params.json, from the generated schema — available now, via ix2nix --schema or the builtins.wasm schema export;
  • boundary-time errors naming the param — port must be 0 to 65535, got 99999 — raised by the CLI in milliseconds, before nix starts, against Ty and not against the generated schema (see below: the schema is the lossy one);
  • ix new worker --help, rendered from the same Ty;
  • an unknown param as a hard error rather than a silently applied default.

The last three are CLI work, and they wait on ix new existing rather than on anything about types.

One representation, two audiences, and the schema is the lossy one. Ty and the JSON Schema come from the same value, and they are not interchangeable: the schema is deliberately looser than the type it was generated from, so a CLI validating against the schema accepts values nix then refuses. float widens to {"type": "number"} because draft 2020-12 counts 2.0 as an integer, and path keeps only its absolute-string branch. So --set is validated against ix2nix::ty::Ty from ix2nix::types, which is exactly as strict as the eval-time check because both are lowered from the same value; the JSON Schema is for editors and params.json completion, where a published, standard-dialect document is what tools consume.

Which makes (params: WorkerParams) the spelling to reach for. A named alias on a single parameter is what an author should write, and the destructured-with-defaults form is the fallback rather than the default. Three reasons, in increasing order of what they cost:

type WorkerParams = {
  instance: nonEmptyStr;
  name: nonEmptyStr;
  port?: port;
  shards?: u32;
};

worker: (params: WorkerParams) =>
  index.lib.mkVm({ name: params.name, /* … */ }),
  1. It is the only form that reaches the generated schema, so it is the only form ix new worker --help or an editor can ever describe. ix2nix refuses an alias on a destructured parameter — a destructured parameter needs an inline object type; there is no binding for the whole set — so the two are exclusive.
  2. Its required cannot lie. For the destructured form the schema derives required from the ? marker while the Nix pattern decides mandatoriness, so ({ a, b }: { a: int; b?: string }) publishes required: ["a"] and then dies at eval with called without required argument 'b'. An alias has no pattern to disagree with, so it is correct there by construction.
  3. It costs the ergonomics: no default per key, so each one becomes params.port or 8080 in the body.

Annotating the template’s own arrow is a requirement on template authors, not a style preference. ix2nix::types reports the parameter types written down in the source; it does not infer them. A template written as worker: ({ index }) => index.lib.mkVm({ … }) with the params record unannotated is not a template with weaker help text, it is a template ix new worker --help cannot describe at all: no traversal of the module can recover a type that was never written. What its author would see is --help listing no parameters and --set accepting any key, because there is nothing to check against.

Such a template stays legal and stays checked — nix refuses an unexpected argument by name, and the module system types everything a param reaches — so the quick case stays quick, and whether the CLI should keep tolerating it is Unresolved question 7. examples/templates/workers is deliberately in that state, so the trade is visible in the tree rather than only described here.

Two gaps remain, and neither is about introspection.

First, __ixTy checks the position it annotates but not inside a composite: a value typed listOf int is checked to be a list, and its elements are not, so the eval-time check is currently weaker than the schema it generates. index#4451 closes that.

Second, and specific to templates: a template author has to choose between the readable params spelling and a schema-reachable one. The schema document describes the argument of export default, which for a default.ix is { index } — not the params of a function nested inside the returned attrset, which is exactly where a template’s params live. index#4453 is that gap: ModuleTypes.parameters is written in one place, the export default arm, and every other arrow reaches the mapper through Ok(self.arrow(arrow)?.expr), which discards .parameters. What does reach the document regardless is every type alias, whether the root refers to it or not. But an alias cannot annotate a destructured parameter:

$ ix2nix < worker.ix
error: a destructured parameter needs an inline object type (`({ a }: { a: T })`);
       there is no binding for the whole set
   --> 11:59
   |
11 |       worker: ({ instance, name, port = 8080, shards = 1 }: WorkerParams) =>
   |                                                           ^

So ({ instance, name, port = 8080 }: { … }) — the form this document has used throughout, with a default per key — is checked at eval time and invisible to the schema, while (params: WorkerParams) is in $defs and gives up destructuring, so every default becomes params.port or 8080 in the body. Measured on a two-template probe, one annotated each way:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "WorkerParams": {
      "type": "object",
      "properties": {
        "instance": { "type": "string", "minLength": 1 },
        "name": { "type": "string", "minLength": 1 },
        "port": { "type": "integer", "minimum": 0, "maximum": 65535 },
        "shards": { "type": "integer", "minimum": 0, "maximum": 4294967295 }
      },
      "required": ["instance", "name"]
    }
  }
}

The root is empty, the named alias is complete and correctly refined down to port’s bounds, and the inline-annotated template appears nowhere.

ix new worker --help is therefore buildable today, against #/$defs/<Name> and a naming convention, for templates written the second way. Closing it properly wants two things from ix2nix: an alias usable on a destructured parameter, and an emitted mapping from template name to params schema, so that no consumer has to guess which $defs entry belongs to which template.

Why not the module system, which RFC 0008 established as this repo’s typed schema language. 0008 is right about Nix-side builder arguments and this does not contradict it. It cannot serve this boundary, for one concrete reason: ix new --help and the rejection of a malformed --set must happen in the Rust CLI before a nix evaluation, and an evalModules schema is only readable by evaluating nix. ix2nix is already a Rust crate, so a schema derived from it is available to the CLI at zero cost. The generated-schema discipline is the same one 0008 applies — one declaration, every other artifact derived from it, never a second copy maintained by hand.

Nickel stays where the original design put it: an optional later contracts layer above JSON for external param files. It evaluates to the same JSON values, so adding it later breaks nothing, and now that ix-ty is introspectable a Nickel contract export is a third lowering of the same Ty rather than a second type system. Note that a curated Nickel config schema was already proposed and closed as NOT_PLANNED (ix#8132, ENG-9345).

Instantiating

$ ix new worker                                  # worker@2, next free ordinal, all defaults
$ ix new worker --set port=9090 --set shards=4
$ ix new worker 7 --set port=9090                # explicit instance: worker@7
$ ix new devbox ml '{"repo":"github:acme/ml"}'   # inline JSON
$ ix new devbox ml ./params.json
$ ix new worker --transient                      # systemd-run semantics
$ ix new worker --name ingest-eu --set port=9090 # opt out of @ naming

--set k=v is sugar for shallow keys, parsing the value as JSON first (--set shards=4 is a number) and falling back to string.

Params are JSON. That is a position, not a preference. A template’s params are a Nix value, and JSON is the exact data subset of both Nix and .ix, so a literal or a file round-trips into the eval with no translation. Not YAML: implicit typing (the Norway problem, where an unquoted no becomes false), significant whitespace inside a CLI argument, and several incompatible dialects buy nothing, because nobody hand-maintains these as config files. They are call arguments.

What gets recorded, server side, next to the VM row:

{
  "vm": "worker@7",
  "template": "worker",
  "params": { "port": 9090, "shards": 4 },
  "source": { "ref": "github:acme/infra", "rev": "e4f1c2a", "workdir": "." },
  "overrides": [],
  "transient": false
}

What ix new actually does. Resolve the config the way ix apply already does, validate params in the CLI against Ty rather than against the JSON Schema (Typed params says why: the schema is the lossy one), call index.lib.templates.renderInstance — which evals templates.<name> with { instance, name } + params, the template’s own defaults filling the rest, and refuses params that restate either injected value — then run the existing flake-converge create path from commands/up.rs with the rendered system, inside the create TUI that tui/apps/new already renders. The only new persistent artifact is the instance record: no new build path, no new image path.

Reconciliation

Unbuilt. Everything in this section is design: it needs the server-side instance record, and it ships with the ix half (ix#9242). What exists today is the instances block, which ix apply converges like any named VM.

The question that kills most designs like this: what does the next ix apply do to a VM someone created with ix new?

  • Declared things converge as today. Named VMs and the instances preset block are created if missing and converged in place.
  • Recorded instances re-render. For every non-transient record whose template lives in this config, apply evaluates the current template with the recorded params plus overrides and converges that VM in place. This is systemctl enable plus daemon-reload.
  • --transient instances are invisible to apply. Rendered once at creation, never re-converged, deleted by ix rm with no record left. For experiments, where “apply upgraded my scratch box mid-bisect” is a bug.
  • Orphans warn, never die. A renamed or deleted template leaves its instances listed and running. The user deletes or re-adopts (ix config adopt worker@7 --template ingester) explicitly.

Drop-in overrides

Unbuilt. So is this section, and for the same reason: an override is a row next to the instance record, so ix config ships with the ix half (ix#9242).

$ ix config set worker@3 shards=8      # records an override, converges the VM
$ ix config edit worker@3              # systemctl edit: opens merged params, stores the diff
$ ix config show worker@3              # systemctl cat + systemd-delta in one

worker@3   template worker   github:acme/infra @ e4f1c2a
  port    9090    # ix new (2026-07-20)
  shards  8       # override (2026-07-23), template default 1, ix new set 4

Merge order, lowest to highest: template defaults, creation params, overrides in recorded order. Every key says which layer won, so “why is this instance different” is one command.

Why overrides live server side and not in the repo. systemd drop-ins are files because units are files. Instance state here is already server side, and putting overrides in the repo would make the repo lie: it would claim to define instances that exist only because someone ran a command. The repo owns the template, reviewed and versioned; the server owns existence and per-instance deltas, auditable and listable. Anything that graduates from an override into policy moves into the template or the instances block, and ix config show makes that drift visible rather than hiding it.

CLI surface

ix new <tmpl> [instance] [params.json | '{...}'] [--set k=v]... [--transient] [--name N] [--region R]
ix apply                       # declared VMs + instances block + recorded instances
ix apply .#worker-3            # converge exactly one instance (a flake attr, so the node name)
ix apply --declared-only       # skip recorded instances (CI that must not touch hand-made VMs)
ix config show|set|edit|adopt <vm>
ix rm worker@3                 # VM and its record
ix rm 'worker@*'               # all instances of a template, with confirmation

ix ls gains a TEMPLATE column.

Migration

Nothing existing is touched. Configs without templates evaluate as before; ix apply .#name, bare ., OCI refs, and snapshot UUIDs keep their shapes and flag sets. ix apply <oci-ref> remains the escape hatch for imperative creates outside any config, and its flag pile is what templates exist to replace.

Every CLI surface deals in instance names while nix deals in node names. ix new worker 3, ix rm worker@3, ix rm 'worker@*', ix config show worker@3 and ix ls’s TEMPLATE column all name worker@3; the flake attribute and the VM are worker-3, so ix apply .#worker-3 is what converges it. The CLI therefore carries exactly one conversion, and it must not reimplement it: call index.lib.templates.parseInstanceName (or instanceNameOf for the reverse) so the spelling has one owner across both repos. An ix ls that prints the node name under a TEMPLATE column, or an ix rm that accepts only one of the two spellings, is the failure mode to watch for.

The verb slot is free, but only recently, and the copy has not caught up. crates/ix/cli/src/run/client_command/new_command.rs implemented ix new <image|snapshot-uuid> on the 8071-terminal-landing worktree, and the landing page terminal was built around a literal ❯ ix new prompt. ix#8134 (PR #8144) absorbed it into ix apply. Reusing the verb means auditing landing-page copy and docs from that era in the same change.

Drawbacks

  • ix apply gains the power to re-render machines nobody declared. That is the feature, and it is also the blast radius: one template edit reaches every instance on the next apply. A reader of the repo can no longer enumerate what apply will touch from the repo alone.
  • A new server-side record that must survive VM migration and appear in ix history.
  • Params are visible to anyone who can read the VM, which is why secrets must not be params, and why that needs a mechanism rather than a sentence.
  • Two ways to create a VM for as long as ix apply <oci-ref> stays.
  • The typed half is half shipped. The schema generator exists (index#4450), so ix new no longer risks growing its own schema surface. What is left is narrower, and stated under Typed params: deep checking of data positions (index#4451), and a template’s params reaching the generated document only if the author gives up destructuring in order to name their type.

Alternatives

  • Do nothing. Copy-paste mkVm calls, and reach for ix apply <oci-ref> with flags for anything ad hoc. The drift stays invisible, which is the fly.io outcome.
  • Pure declarative, Terraform for_each style. Powerful, and adding one worker means editing code and re-planning the world. That friction is exactly what ix new removes.
  • Kubernetes Deployments: a replica count with anonymous instances. Proven at scale, and wrong here: ix instances are named and individually owned, which is why systemd’s @ naming fits and a replica count does not.
  • Module-option params instead of .ix annotations. Discussed under Typed params: it cannot give the Rust CLI a schema without a nix evaluation.
  • Nickel as the params language. Already proposed and closed (ix#8132 / ENG-9345). It is a layer above JSON, not a replacement for it.

Prior art

  • systemd template units, drop-ins, systemctl edit, systemd-delta, systemd-run. The whole design is a translation.
  • fly.io: the same imperative/declarative split, arrived at accidentally and without a record. The hole this closes.
  • Kubernetes Deployments: template rendered per instance, controller converges.
  • NixOS specialisations: variants as functions over a base config rather than textual overlays.
  • RFC 0008: one declaration, every other artifact derived. Same discipline, different boundary.
  • packages/mkapp: the source of the passthru.tests.scaffold idea — a gate that refuses to emit an incomplete scaffold. Removed in indexable-inc/index#4381; it was the repo’s only template system until then, so read the code in that pull request rather than looking for it in the tree.

Unresolved questions

  1. Template-edit blast radius. Should ix apply diff-preview per instance, and should a record pin the rev it last converged to so ix config show can say “3 applies behind”?
  2. Secrets must not be params, and that is currently a rule with no mechanism. Templates should reference the existing ix secret store by name. Should a param be markable as secret-ref typed, and should ix new reject values that look like credentials?
  3. Region: param or flag? Leaning flag: region is placement, not configuration, and mixing them makes templates non-portable.
  4. Config identity. What happens when the same template name exists in two configs the user applies? Likely: records key on (source, template), and apply only touches its own.
  5. Orphan re-adoption. Is ix config adopt the right shape, or should a renamed template carry a rename hint?
  6. Reaching a template’s params type needs index#4453, and two spellings put it out of reach of a single-file walk, so this document should say which one it blesses. Indirection through a top-level binding (const templates = { worker: … }; export default ({ index }) => ({ templates })) needs an identifier resolved to its let binding — possible inside one module, but a resolver rather than a traversal. Splitting the template into its own file (worker: import("./worker.ix")) puts the type in a file the converter never sees; the specifier is a literal relative path, so a Rust caller can follow it, but only if it is told to. And types() must report an unresolved import as an unresolved import, never as a template with no parameters: those two outcomes are indistinguishable to a caller and only one of them is a reason to stop.
  7. Should the CLI refuse to register a template whose params arrow is unannotated? Refusing makes ix new <template> --help trustworthy, because every registered template can then describe itself. It also makes a quick untyped template impossible, which contradicts “a bare untyped function stays legal” above, and that tension is the decision: an empty help page silently offered is the other way to be wrong.

Future possibilities

  • Instances of one template referencing instances of another. Deliberately out of scope for v1; templates that need nodes from fixed VMs already work.
  • A Nickel contract export as a third lowering of Ty, beside the checker and the JSON Schema, for external param files that want ranges and cross-field checks.
  • Ambient .d.ts declarations generated from the same Ty, so an editor type-checks a .ix file without a nix evaluation. packages/ix2nix/src/ty.rs says of the static half that “tsc against ambient declarations still never runs here”.
  • A templates flake output. flake.nix:535 reads templates = {}; and RFC 0007 already reserved templates.dev for ix dev init. A starter template that scaffolds a default.ix containing templates is the other half of this story, and is a different feature from instantiating one.
© 2026 Indexable, Inc.