Templating
Processing Pipeline
Section titled “Processing Pipeline”- Read the raw file.
- Template: Go
text/templatewith 100+ Sprout functions runs over the raw text. - YAML parse: the output is parsed as YAML, resolving anchors (
&name/*alias) and merge keys (<<:). - Validation: the strict schema rejects unknown fields (one exception:
anchor_keys, below).
Consequences: template output must be valid YAML, templates run before anchors resolve (so the two compose), and schema errors surface only after templating, as a failure mode distinct from template errors (see Troubleshooting).
Syntax
Section titled “Syntax”| Construct | Example | Purpose |
|---|---|---|
| Action | {{ env "MY_URL" }} | Outputs a value (here: the MY_URL env var) |
| Pipeline | {{ env "PORT" | default "22" }} | Passes the result through functions left to right |
| Variable | {{ $tag := "prod" }} | Assigns an in-template variable |
| Comment | {{/* this does nothing */}} | Template comment, removed from output |
| Trim | {{- value -}} | Removes surrounding whitespace/newlines from output |
Pipelines are the core idiom: {{ <value> | <func> | <func> }}.
Functions
Section titled “Functions”Go built-ins (eq, ne, and, or, not, printf, len, index, …) are always available, as are 100+ Sprout functions covering environment, strings, conversion, math, time, and regexp. The complete list lives in the Sprout registry docs.
Idioms that come up constantly:
| Need | Idiom |
|---|---|
| Env var with fallback | {{ env "X" | default "y" }} |
Expand $VAR in a string | {{ expandEnv "$HOME/.ssh/id_ed25519" }} |
| Ternary | {{ ternary "yes" "no" (eq (env "ENV") "prod") }} |
| Number coercion | {{ toInt (env "SSH_PORT" | default "22") }} |
Control Flow
Section titled “Control Flow”if/else/end can wrap whole YAML sections; the engine operates on raw text, so this renders to one of the two flakes, never both:
fleet: flakes: {{ if eq (env "ENV") "production" }} prod-flake: url: github:myorg/prod-config {{ else }} dev-flake: url: github:myorg/dev-config {{ end }}with scopes the dot; range iterates lists (see Pitfalls).
Variables & Reusable Snippets
Section titled “Variables & Reusable Snippets”Assign values used several times with := ({{ $ssh_port := env "SSH_PORT" | default "22" }}, then reference {{ $ssh_port }} anywhere). Larger snippets are defined once and invoked with parameters. Definitions must sit inside YAML comments so the YAML parser never sees them:
# {{define "cryptenroll"}}systemd-cryptenroll --tpm2-device=auto {{.}}{{end}}
# ...per machine:bootstrap: post_bootstrap_hooks: - | {{template "cryptenroll" "/dev/sda2"}}Templates are processed before YAML parsing, so the directive is gone by the time YAML runs. Inside the definition, {{.}} is the argument passed by {{template "name" arg}}.
YAML Anchors & Merge Keys
Section titled “YAML Anchors & Merge Keys”&name marks a value, *name references it:
anchor_common_ssh: &common_ssh hostname: 10.0.200.10 username: root port: 22
# ...in fleet, per machine:ssh: *common_ssh<<: *alias merges the aliased mapping’s top-level keys into the current mapping; explicit keys override merged values regardless of position, so you can inherit defaults and specialize per machine:
server-01: bootstrap: <<: *bootstrap_defaults kexec: image: ./kexec-x86_64.tar.gz # overrides the anchor's imageAnchors work on lists too: secrets and disk_encryption_keys that repeat across machines can be defined under &disks and referenced as *disks.
By convention, anchor definitions live at the top level of the file and carry no meaning to the schema.
Templates run first, so they compose with anchors freely: templates can generate anchor definitions, and anchored blocks can contain template invocations. The deploy example combines both (see below).
Merge-key caveats:
- The merge key merges top-level mapping entries only; for a nested merge, merge at each level explicitly.
- An alias is a reference:
anchor_-prefixed keys pass validation but are inert.
Pitfalls
Section titled “Pitfalls”The rendered result must remain valid YAML; these are the failure modes that bite:
-
Keep control flow at a consistent column.
{{ if }}/{{ end }}render to empty lines (harmless); differently-indented wrapped content is what breaks. -
No control flow inside block scalars. A
{{ if }}inside a|scalar silently removes a line of the command. Move the condition outside. -
Trim when generating list items. A
rangeloop leaves blank lines between items:tags:{{- range $t := list "web" "prod" }}- {{ $t }}{{- end }} -
Watch the target type. Templates emit text; YAML decides what it parses as. Strings may be quoted, numbers and booleans must not (
port: {{ env "SSH_PORT" | default "22" }}). An empty render leaveskey:null; usedefault.
yaml-language-server may flag {{/}} and comment-based {{define}} lines; this is cosmetic, since templates run before YAML. See IDE Support for the full setup.
Debugging
Section titled “Debugging”Two commands inspect what your config becomes (see Other Commands):
panix template -c panix.yml -o processed.yml: templates and anchors resolved, no semantic validation: YAML syntax and unknown-key checks still apply, but flake URLs, paths, and inheritance are not resolved. Output contains onlyflags,fleet, andoutput_types(anchor_keys resolved away).panix eval -c panix.yml -o evaluated.yml: the full pipeline, including defaults, inheritance, and validation.
Troubleshooting
Section titled “Troubleshooting”Template errors are wrapped (failed to process templates in config); validation failures surface as invalid configuration.
| Symptom | Meaning |
|---|---|
failed to parse template | Template syntax error (missing {{, broken define, unknown function name) |
failed to execute template | A function failed at runtime (bad argument) |
failed to decode config | The processed text is not valid YAML, or violates the strict schema (unknown field) |
unknown field "my_anchor" | Missing the anchor_ prefix on an anchor definition key |
Putting It Together
Section titled “Putting It Together”Anchored defaults, merged overrides, a template-defined hook, and an environment-gated machine in one config:
# {{define "cryptenroll"}}systemd-cryptenroll --unlock-key-file=/tmp/disko-encryption-password.txt --tpm2-device=auto --tpm2-with-pin=no --wipe-slot=all {{.}}{{end}}
anchor_bootstrap: &bootstrap_defaults kexec: image: ./kexec-$PANIX_ARCH.tar.gz disk_encryption_keys: - local_path: /tmp/disko-encryption-password.txt remote_path: /tmp/disko-encryption-password.txt
fleet: flakes: infrastructure: url: ../infrastructure installables: nixosConfigurations: server-01: machines: server-01: ssh: hostname: "{{ env "SERVER_HOST" | default "server-01.internal" }}" bootstrap: <<: *bootstrap_defaults post_bootstrap_hooks: - | {{template "cryptenroll" "/dev/sda2"}} {{ if eq (env "DEPLOY_LAPTOP") "true" }} personal-laptop: machines: personal-laptop: bootstrap: <<: *bootstrap_defaults {{ end }}The e2e test config exercises every feature on this page against real machines.