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 --full option 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. Per-node configuration sits in a single modeline-annotated nodes/<name>.yaml; the first -f file is the anchor and any subsequent -f files stack onto its rendered config as side-patches (see "Apply with side-patches" below).
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 talm
Download 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 -s
Windows 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 myawesomecluster
talm 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.
To set the Kubernetes control-plane URL at init time, pass --cluster-endpoint:
talm init -p cozystack -N myawesomecluster --cluster-endpoint https://vip.example.test:6443
--cluster-endpoint writes the URL into values.yaml::endpoint, which the chart renders into cluster.controlPlane.endpoint of every node's MachineConfig (the URL kubelet and kube-proxy dial). The flag is honored on initial init only — for an existing project, edit values.yaml directly.
--endpoints and --cluster-endpoint address different concepts: --endpoints (plural, list) populates the talosconfig context for the talosctl client; --cluster-endpoint (singular, full URL) populates the Kubernetes control-plane address inside the chart. When --endpoints is given a single value, init auto-derives values.yaml::endpoint as https://<that>:6443 — the single-target case is unambiguous. Multi-endpoint inputs never auto-derive (picking one node would silently couple cluster availability to it); the operator must pass --cluster-endpoint explicitly or fill values.yaml::endpoint later. The init flow prints a hint at the end when the field is left empty.
Edit values.yaml to set your cluster's control-plane endpoint if neither flag set it. This is the URL every node's kubelet and kube-proxy will dial. The chart leaves it empty by default so a missed override fails loudly instead of silently embedding a placeholder.
Endpoint / floatingIP combinations:
endpoint and floatingIP together to the same IP — single shared VIP.endpoint to the node's routable IP and leave floatingIP blank.endpoint to the LB URL and leave floatingIP blank.When vipLink is left empty the chart picks the link automatically using a two-step rule:
floatingIP falls 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.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 --nodes 192.0.2.4 -e 192.0.2.4 template -t templates/controlplane.yaml -i > nodes/node1.yaml
Edit 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:6443
Note: 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-time safety gates.
talm applyandtalm upgraderun additional gates around each operation:
Declared-resource existence (
--skip-resource-validationopt-out, default on). Before sending the config to the node, the gate walks the rendered MachineConfig, extracts every reference to a host-side resource (network links from v1.12 multi-doc —LinkConfig.name,BondConfig.links[],VLANConfig.parent,BridgeConfig.links[],Layer2VIPConfig.link,HCloudVIPConfig.link,DHCPv4Config.name/DHCPv6Config.name/EthernetConfig.name; v1.11 legacymachine.network.interfaces[].interface; install disk viamachine.install.diskliteral ormachine.install.diskSelector;UserVolumeConfig.provisioning.diskSelector), and verifies each against the node's COSILinkStatus/Disksnapshots. A reference that doesn't resolve fails the apply with a[blocker]line listing the available names so the typo or migration miss is fixable from the values without re-running discovery. Disk selectors must match at least one (non-readonly, non-CDROM, non-virtual) disk — zero matches block, multiple matches warn (install picks the first). Virtual-link-creator documents (BondConfig.name,VLANConfig.name,BridgeConfig.name,WireguardConfig.name,DummyLinkConfig.name,LinkAliasConfig.name) are intentionally NOT validated against existing links — those.namefields describe new virtual links the apply is creating, not references to pre-existing host resources. The gate also runs a syntactic net-addr walker againstStaticHostConfig.name(must parse as an IP literal — thenamefield on this kind doubles as the IP the hostnames map to),NetworkRuleConfig.ingress[].subnetand.except(per-entry CIDR), andWireguardConfig.peers[].endpoint(host:port; empty / absent endpoint is a listener-only peer, NOT a finding). Out of scope today:machine.disks[].device(extra-disk partitioning); track in a follow-up if you need it. Pass--skip-resource-validationfor recovery into a maintenance image with mismatched hardware or pre-staging values for hardware that isn't installed yet.Pre-apply drift preview (
--skip-drift-previewopt-out, default on). Reads the node's current MachineConfig via COSI and prints a+/-/~/=diff of what's about to
—