Skip to content

Templating

  1. Read the raw file.
  2. Template: Go text/template with 100+ Sprout functions runs over the raw text.
  3. YAML parse: the output is parsed as YAML, resolving anchors (&name / *alias) and merge keys (<<:).
  4. 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).

ConstructExamplePurpose
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> }}.

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:

NeedIdiom
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") }}

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).

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}}.

&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 image

Anchors 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.

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 range loop 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 leaves key: null; use default.

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.

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 only flags, fleet, and output_types (anchor_ keys resolved away).
  • panix eval -c panix.yml -o evaluated.yml: the full pipeline, including defaults, inheritance, and validation.

Template errors are wrapped (failed to process templates in config); validation failures surface as invalid configuration.

SymptomMeaning
failed to parse templateTemplate syntax error (missing {{, broken define, unknown function name)
failed to execute templateA function failed at runtime (bad argument)
failed to decode configThe 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

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.