Manage Talos the GitOps Way!
Talm is just like Helm, but for Talos Linux
While developing Talm, we aimed to achieve the following goals:
-
Automatic Discovery: In a bare-metal environment, each server may vary slightly in aspects such as disks and network interfaces. Talm enables discovery of node information, which is then used to generate patches.
-
Ease of Customization: You can customize templates to create your unique configuration based on your environment. The templates use the standard Go templates syntax, enhanced with widely-known Helm templating logic.
-
GitOps Friendly: The patches generated do not contain sensitive data, allowing them to be stored in Git in an unencrypted, open format. For scenarios requiring complete configurations, the
--fulloption allows the obtain a complete config that can be used for matchbox and other solutions. -
Simplicity of Use: You no longer need to pass connection options for each specific server; they are saved along with the templating results into a separate file. This allows you to easily apply one or multiple files in batch using a syntax similar to
kubectl apply -f node1.yaml -f node2.yaml. -
Compatibility with talosctl: We strive to maintain compatibility with the upstream project in patches and configurations. The configurations you obtain can be used with the official tools like talosctl and Omni.
For macOS and Linux users, the recommended way to install talm is with Homebrew.
brew install talmDownload binary from Github releases page
Or use simple script to install it:
curl -sSL https://github.com/cozystack/talm/raw/refs/heads/main/hack/install.sh | sh -sWindows is supported. Download the talm-windows-*.zip archive from the releases page and extract talm.exe. On Windows, template paths passed to the -t / --template flag accept either \ or / separators, so -t templates\controlplane.yaml and -t templates/controlplane.yaml are equivalent. Other path flags (--talosconfig, -f / --file) are delegated to the underlying OS file loader and follow standard Windows path rules.
Create new project
mkdir newcluster
cd newcluster
talm init -p cozystack -N myawesomeclustertalm init refuses to run when the current directory is inside an existing talm project (it would otherwise walk up and partially overwrite the parent). To create a project under the current directory anyway — e.g. a sub-project nested inside another talm project — pass --root . explicitly. To re-initialise the parent itself, run talm init from the parent directory.
To pin a specific Talos installer image at init time (e.g. a Talos Factory image with extensions), pass --image:
talm init -p cozystack -N myawesomecluster --image factory.talos.dev/installer/<sha256>:<version>--image rewrites the top-level image: field in the preset's values.yaml before write. The flag is honored on initial init only — for an existing project, edit values.yaml directly. The cozystack preset declares image:; the generic preset does not, so --image --preset generic is rejected up front.
Edit values.yaml to set your cluster's control-plane endpoint. This is the URL every node's kubelet and kube-proxy will dial. The chart leaves it empty on purpose so a missed override fails loudly instead of silently embedding a placeholder.
Endpoint / floatingIP combinations:
- cozystack VIP setup: set
endpointandfloatingIPtogether to the same IP — single shared VIP. - single-node cluster: set
endpointto the node's routable IP and leavefloatingIPblank. - multi-node with external load balancer: set
endpointto the LB URL and leavefloatingIPblank.
When vipLink is left empty the chart picks the link automatically using a two-step rule:
- Longest-prefix match across configurable links. If
floatingIPfalls inside the CIDR of any address on a configurable link (physical NIC, bond, VLAN, bridge), the most specific subnet wins. This handles the Hetzner-style topology where a public NIC carries the default route and a VLAN child carries the private cluster subnet — the VIP lands on the VLAN child. - Fallback to the IPv4-default-gateway-bearing link. Used when no configurable link's CIDR contains the
floatingIP— typical for upstream-routable VIPs that arrive via the default route.
Addresses on links the chart does not emit a per-link document for (Wireguard, kernel-managed loopback, slave NICs of a bond, anything outside the configurable set) are skipped — a VIP pinned there would have no surrounding network document.
Set vipLink explicitly when the target link does not yet exist on the live system at first apply (typically a VLAN sub-interface). The chart pins Layer2VIPConfig.link to it directly and emits the document even on a fresh node where discovery has not yet populated the addresses table. The chart does not auto-emit a LinkConfig or VLANConfig for the override link; the operator is responsible for ensuring the link comes up, typically by adding a LinkConfig or VLANConfig for that link to the per-node body overlay alongside vipLink.
Subnet-selector fields (kubelet.validSubnets, etcd.advertisedSubnets) are derived automatically from the node's default-gateway-bearing link, so no override is needed unless you have a multi-homed node that requires a specific subnet pinned.
Boot Talos Linux node, let's say it has address 192.0.2.4. Then:
# values.yaml (single-node example matching the 192.0.2.4 node below)
endpoint: "https://192.0.2.4:6443"
floatingIP: ""Gather node information:
talm -n 192.0.2.4 -e 192.0.2.4 template -t templates/controlplane.yaml -i > nodes/node1.yamlEdit nodes/node1.yaml file:
# talm: nodes=["192.0.2.4"], endpoints=["192.0.2.4"], templates=["templates/controlplane.yaml"]
machine:
network:
# -- Discovered interfaces:
# enx9c6b0047066c:
# name: enp193s0f0
# mac:9c:6b:00:47:06:6c
# bus:0000:c1:00.0
# driver:bnxt_en
# vendor: Broadcom Inc. and subsidiaries
# product: BCM57414 NetXtreme-E 10Gb/25Gb RDMA Ethernet Controller)
# enx9c6b0047066d:
# name: enp193s0f1
# mac:9c:6b:00:47:06:6d
# bus:0000:c1:00.1
# driver:bnxt_en
# vendor: Broadcom Inc. and subsidiaries
# product: BCM57414 NetXtreme-E 10Gb/25Gb RDMA Ethernet Controller)
interfaces:
- interface: enx9c6b0047066c
addresses:
- 192.0.2.4/26
routes:
- network: 0.0.0.0/0
gateway: 192.0.2.1
nameservers:
- 8.8.8.8
- 8.8.4.4
install:
# -- Discovered disks:
# /dev/nvme0n1:
# model: SAMSUNG MZQL21T9HCJR-00A07
# serial: S64GNE0RB00153
# wwid: eui.3634473052b001530025384500000001
# size: 1.75 TB
# /dev/nvme1n1:
# model: SAMSUNG MZQL21T9HCJR-00A07
# serial: S64GNE0R811820
# wwid: eui.36344730528118200025384500000001
# size: 1.75 TB
disk: /dev/nvme0n1
type: controlplane
cluster:
clusterName: talm
controlPlane:
endpoint: https://192.0.2.4:6443Note: output format depends on Talos version.
Selected via
Chart.yaml(templateOptions.talosVersion) or--talos-version:
- Talos < v1.12 — single YAML document with
machine.networkandmachine.registriessections (as shown above).- Talos >= v1.12 — multi-document format with separate typed documents instead of the deprecated monolithic fields.
For v1.12+ multi-doc output, one document is emitted per configurable link on the node, plus a fixed pair on every render:
HostnameConfigandResolverConfig— always emitted.LinkConfig— physical NICs.BondConfig— bond masters. Bond slaves are filtered out so they do not collide with the master's document.VLANConfig— VLAN sub-interfaces.BridgeConfig— bridges, symmetric toBondConfigfor bonds. Ports discovered viaspec.slaveKind == "bridge"+spec.masterIndex; STP / VLAN-filtering settings reach the output when the bridge controller reports them onspec.bridgeMaster.Layer2VIPConfig— controlplane nodes whenfloatingIPis set.RegistryMirrorConfig— cozystack chart only.Per-link emission rules:
- The link carrying the IPv4 default route gets the
routes.gatewayentry on its document; every other link is emitted gateway-less. Applies uniformly toLinkConfig,BondConfig,VLANConfig,BridgeConfig.- Both IPv4 and IPv6 global-scope addresses on a link are surfaced.
- The operator-declared
floatingIPis stripped from per-link addresses so the VIP currently held by a leader does not leak into the static document.Multi-NIC nodes therefore produce one document per NIC, not one document total.
Version compatibility (
templateOptions.talosVersion/--talos-version). This setting must match the Talos version actually running on the target node — i.e. the maintenance ISO/PXE the node booted from forapply -i, or the installed Talos for an authenticated apply. It is not the same asinstall.image, which only controls what gets written to disk after a successful apply. When the configured contract is newer than the running binary, machinery injects fields (e.g.machine.install.grubUseUKICmdlinefrom v1.12) that the running parser does not know, and the apply fails on the node side withfailed to parse config: unknown keys found during decoding: ....talm applyruns a best-effort pre-flight check against the running version and prints awarning: pre-flight: ...line with a hint when it detects this mismatch; if the warning is missed, the same hint is appended to the apply error. Either reboot the node into a maintenance image that matches the configured contract, or lowertemplateOptions.talosVersion/--talos-versionto match what is running.
Apply config:
talm apply -f nodes/node1.yaml -iUpgrade node:
talm upgrade -f nodes/node1.yamlShow diff:
talm apply -f nodes/node1.yaml --dry-runRe-template and update generated file in place (this will overwrite it):
talm template -f nodes/node1.yaml -I
Per-node patches inside node files. A node file can carry Talos config below its modeline (for example, a custom
hostname, secondary interfaces withdeviceSelector, VIP placement, or extra etcd args). Whentalm apply -f node.yamlruns the template-rendering branch, that body is applied as a strategic merge patch on top of the rendered template before the result is sent to the node — so per-node fields survive even when the template auto-generates conflicting values (e.g.hostname: talos-XXXXX).Talos v1.12+ caveat. The multi-document output format introduced in v1.12 splits network configuration into typed documents (
LinkConfig,BondConfig,VLANConfig,Layer2VIPConfig,HostnameConfig,ResolverConfig). Legacy node-body fields undermachine.network.interfaceshave no safe 1:1 mapping to those types and the chart cannot translate them yet — pin per-node network settings by patching the typed resources (e.g. aLinkConfigdocument below the modeline) rather than legacymachine.network.interfaces. Fields outside the network area (machine.network.hostnameviaHostnameConfig,machine.install.disk, extra etcd args, etc.) still merge as expected.Upgrade-from-legacy guardrail. Nodes originally bootstrapped on a chart that emitted the legacy schema still carry
machine.network.interfaces[]in their runningMachineConfig. On v1.12 multi-doc rendering the chart cannot reconstruct equivalent typed documents from those entries automatically, and silently dropping them on the next apply would erase the user's network declarations. The renderer therefore fails the render withtalm: the multi-doc renderer cannot translate legacy machine.network.interfaces[] from the running MachineConfig..., spelling out the migration path: move the interfaces, vlans, and addresses into per-node body overlays as v1.12 typed documents (LinkConfig,VLANConfig,BondConfig,RouteConfig) before re-runningtalm apply, or pintemplateOptions.talosVersion: "v1.11"inChart.yamluntil the translator lands.One body, one node. A non-empty body is a per-node pin, so the modeline for that file must target exactly one node.
talm applyrefuses a multi-node modeline when the body is non-empty; modeline-only files (no body) are still allowed and drive the same rendered template on every listed target.Idempotent applies. Repeated
talm applyruns against an already-configured node do not duplicate entries. Before the strategic merge runs, the engine prunes from the body every primitive-list entry the rendered template already carries (e.g. certSANs, nameservers, validSubnets). For object arrays the upstream patcher merges by identity (machine.network.interfaces byinterface:ordeviceSelector:, vlans byvlanId:, apiServer admissionControl byname:), the prune descends into matched pairs and dedupes the inner primitive lists too — so re-applying aftertalm template -Idoes not double interface addresses, vlan addresses, or admission-control exemption namespaces. For object arrays without an upstream identity merge (extraVolumes, kernel.modules, wireguard.peers, ...), body items that deep-equal a rendered counterpart are dropped, covering the dominant full-restate case. Fields taggedmerge:"replace"upstream are passed through verbatim — pruning them would let the upstream replace silently drop the rendered entries on a partial edit. This covers v1alpha1 root pathscluster.network.podSubnets,cluster.network.serviceSubnets,cluster.apiServer.auditPolicy, and the typedNetworkRuleConfigpathsingressandportSelector.ports.
talm template -f node.yaml(with or without-I) does not apply the same overlay: its output is the rendered template plus the modeline and the auto-generated warning, byte-identical to what the template alone would produce. Routing it through the patcher would drop every YAML comment (including the modeline) and re-sort keys, breaking downstream commands that read the file back. Useapply --dry-runif you want to preview the exact bytes that will be sent to the node.
Talm offers a similar set of commands to those provided by talosctl. However, you can specify the --file option for them.
For example, to run a dashboard for three nodes:
talm dashboard -f node1.yaml -f node2.yaml -f node3.yaml
You're free to edit template files in ./templates directory.
All the Helm and Sprig functions are supported, including lookup for talos resources!
Lookup function example:
{{ lookup "nodeaddresses" "network" "default" }}
- is equivalent to:
talosctl get nodeaddresses --namespace=network defaultQuerying disks map example:
{{ range .Disks }}{{ if .system_disk }}{{ .device_name }}{{ end }}{{ end }}
- will return the system disk device name
Talm provides built-in encryption support using age encryption. Sensitive files are encrypted with their values stored in SOPS format (ENC[AGE,data:...]), while YAML keys remain unencrypted for better readability.
To encrypt all sensitive files (secrets.yaml, talosconfig, kubeconfig):
talm init --encrypt
# or
talm init -eThis command will:
- Generate
talm.keyif it doesn't exist - Encrypt
secrets.yaml→secrets.encrypted.yaml - Encrypt
talosconfig→talosconfig.encrypted - Encrypt
kubeconfig→kubeconfig.encrypted(if exists) - Update
.gitignorewith sensitive files
To decrypt all encrypted files:
talm init --decrypt
# or
talm init -dThis command will:
- Decrypt
secrets.encrypted.yaml→secrets.yaml - Decrypt
talosconfig.encrypted→talosconfig - Decrypt
kubeconfig.encrypted→kubeconfig(if exists) - Update
.gitignorewith sensitive files
The talm.key file is generated in age keygen format and contains:
- Creation timestamp
- Public key (for sharing)
- Private key (keep secure!)
Important: Always backup your talm.key file! Without it, you won't be able to decrypt your encrypted secrets. The key file is automatically added to .gitignore to prevent accidental commits.
Encrypted files (*.encrypted.yaml, *.encrypted) can be safely committed to Git, while plain files (secrets.yaml, talosconfig, kubeconfig, talm.key) are ignored.