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
- 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.
| systemd | ix equivalent |
|---|---|
foo.service | named VM in default.ix, unchanged |
foo@.service template | templates.worker, a function in default.ix |
systemctl start foo@bar | ix new worker bar |
%i specifier | instance, 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/*.conf | override layer on the instance record |
systemctl edit | ix config edit worker@3 |
systemctl cat / systemd-delta | ix config show worker@3, with per-key provenance |
systemctl enable | the instance record itself |
systemd-run transient unit | ix new --transient |
| preset files | the instances block in default.ix |
daemon-reload | ix 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:
ix applymay re-converge instances but must never create or destroy an instance it did not declare.- Deleting a template from the repo orphans its instances with a loud warning rather than collecting them. They are billed machines with disks.
- 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, viaix2nix --schemaor thebuiltins.wasmschemaexport; - boundary-time errors naming the param — port must be 0 to 65535, got 99999 —
raised by the CLI in milliseconds, before nix starts, against
Tyand not against the generated schema (see below: the schema is the lossy one); ix new worker --help, rendered from the sameTy;- 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, /* … */ }), - It is the only form that reaches the generated schema, so it is the only form
ix new worker --helpor 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. - Its
requiredcannot lie. For the destructured form the schema derivesrequiredfrom the?marker while the Nix pattern decides mandatoriness, so({ a, b }: { a: int; b?: string })publishesrequired: ["a"]and then dies at eval withcalled without required argument 'b'. An alias has no pattern to disagree with, so it is correct there by construction. - It costs the ergonomics: no default per key, so each one becomes
params.port or 8080in 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
instancespreset 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 enableplusdaemon-reload. --transientinstances are invisible to apply. Rendered once at creation, never re-converged, deleted byix rmwith 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 applygains 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 newno 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
mkVmcalls, and reach forix apply <oci-ref>with flags for anything ad hoc. The drift stays invisible, which is the fly.io outcome. - Pure declarative, Terraform
for_eachstyle. Powerful, and adding one worker means editing code and re-planning the world. That friction is exactly whatix newremoves. - 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
.ixannotations. 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 thepassthru.tests.scaffoldidea — 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
- Template-edit blast radius. Should
ix applydiff-preview per instance, and should a record pin the rev it last converged to soix config showcan say “3 applies behind”? - Secrets must not be params, and that is currently a rule with no
mechanism. Templates should reference the existing
ix secretstore by name. Should a param be markable as secret-ref typed, and shouldix newreject values that look like credentials? - Region: param or flag? Leaning flag: region is placement, not configuration, and mixing them makes templates non-portable.
- 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. - Orphan re-adoption. Is
ix config adoptthe right shape, or should a renamed template carry a rename hint? - 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 itsletbinding — 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. Andtypes()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. - Should the CLI refuse to register a template whose params arrow is
unannotated? Refusing makes
ix new <template> --helptrustworthy, 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
nodesfrom 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.tsdeclarations generated from the sameTy, so an editor type-checks a.ixfile without a nix evaluation.packages/ix2nix/src/ty.rssays of the static half that “tsc against ambient declarations still never runs here”. - A
templatesflake output.flake.nix:535readstemplates = {};and RFC 0007 already reservedtemplates.devforix dev init. A starter template that scaffolds adefault.ixcontaining templates is the other half of this story, and is a different feature from instantiating one.
Claude
Opus
4.6
(AI
agent)
commissioned
by
andrewgazelka