Upgrades, Installer & Imager
Talos does not have a package manager and it does not patch itself in place. The operating system is an immutable, versioned image, and an "upgrade" is nothing more than booting a different image. To make that safe, the upgrade is an atomic transaction over two boot slots with automatic revert; the code that performs it ships inside the version you are moving to, as a one-shot container; and a tiny META partition carries the one piece of state that has to outlive the reboot. This chapter follows an upgrade from talosctl upgrade down through the installer, the imager that built the image in the first place, the bootloaders, and META.
#The OS-as-image model & A/B slots
On a conventional distribution an upgrade mutates the running root filesystem: apt unpacks new files over the old ones, and if the machine dies halfway you are left with a half-upgraded system. Talos rejects that entirely. As we saw in Block, Storage & Volumes, the root is a read-only squashfs that nothing — not even an upgrade — writes into. So upgrading cannot mean changing the OS; it can only mean replacing it with another whole image and booting that.
The mechanism is A/B (dual) boot slots, labelled A and B. The system disk carries both. At any moment one slot is active (it holds the kernel/initramfs or UKI you are currently running) and the other is idle. An upgrade writes the new assets into the inactive slot, flips the bootloader's default to point at it, and reboots. The slot you came from is left intact as the fallback. Nothing about the running system is touched until the very last flip, and the previous image is still bootable the instant anything goes wrong.
Image, not packages
No apt, no dnf, no live patching. An upgrade boots a different signed image. The root squashfs is never mutated, so a partial upgrade is structurally impossible.
A/B slots
Write the new kernel/UKI to the inactive slot, flip the bootloader default, keep the old slot as fallback. The flip is the only mutation, and it is reversible.
Installer is a container
The target version's installer image installs itself. Partitioning and bootloader logic always matches the image being written — never the old host's idea of it.
That third point is the quietly clever one. Talos never installs itself with host tooling. Both first-install and upgrade run the installer image — ghcr.io/siderolabs/installer:<version> — as a one-shot containerd container. Because you pull the installer for the version you are upgrading to, the code that lays down partitions and bootloader entries is exactly the code that shipped with that release. There is no "old installer trying to understand a new layout" failure mode.
#The upgrade flow
An upgrade begins with a single API call. talosctl upgrade --image <ref> sends a gRPC UpgradeRequest to the node:
# move this node to a new image; stage it for next boot if pods can't drain live
talosctl --nodes 10.0.0.5 upgrade \
--image ghcr.io/siderolabs/installer:v1.14.0 \
--stage=false \
--reboot-mode default
# watch it converge
talosctl --nodes 10.0.0.5 dmesg --follow
The server handler (internal/server/v1alpha1/v1alpha1_server.go) does the gating before any disk is touched: it pulls and validates the installer image with install.PullAndValidateInstallerImage, and for control-plane nodes it acquires an etcd upgrade mutex and runs etcdClient.ValidateForUpgrade so two control-plane nodes can't upgrade into a quorum loss at once. It then branches on mode:
- Default. Runs
runtime.SequenceUpgradeimmediately on the live node. - Staged (
--stage). Writes the target image ref + serialized install options into META and runsSequenceStageUpgrade, which merely reboots; theInstallsequence then performs the upgrade on the next boot, before the OS comes up. This is for nodes whose pods can't be drained while live. - Maintenance.
SequenceMaintenanceUpgrade— a node booted in maintenance mode with no config yet.
The staging writes are the cross-boot handoff: the target image ref and the install options are stamped into META keys and flushed to disk so the next boot can find them.
if ok, err := s.Controller.Runtime().State().Machine().Meta().SetTag(ctx, meta.StagedUpgradeImageRef, in.GetImage()); !ok || err != nil {
return nil, fmt.Errorf("failed to set staged upgrade image ref: %w", err)
}
if ok, err = s.Controller.Runtime().State().Machine().Meta().SetTag(ctx, meta.StagedUpgradeInstallOptions, string(serialized)); !ok || err != nil {
return nil, fmt.Errorf("failed to set staged upgrade install options: %w", err)
}
if err = s.Controller.Runtime().State().Machine().Meta().Flush(); err != nil {
return nil, err
}
For the non-staged path, the Upgrade sequence is a fixed phase chain (declared in runtime/v1alpha1/v1alpha1_sequencer.go). It cordons and drains the node, stops every pod and service, unmounts the ephemeral partition, performs the upgrade, reloads META, optionally prepares a kexec, then reboots:
phases.AppendWhen(!opts.SkipNodeRegistration(), "drain", CordonAndDrainNode).
Append("cleanup", StopAllPods).
Append("unmountSystem", UnmountEphemeralPartition).
Append("upgrade", Upgrade).
Append("meta", ReloadMeta).
AppendWhen(in.GetRebootMode() == machineapi.UpgradeRequest_DEFAULT, "kexec", KexecPrepare).
Append("stopEverything", StopAllServices).
Append("reboot", Reboot)
The Upgrade task itself does not contain any partitioning logic — it just runs the installer container against the system disk, handing it the target image and upgrade options:
err = install.RunInstallerContainer(
devname, r.State().Platform().Name(),
in.GetImage(), r.Config(), r.ConfigContainer(),
r.State().V1Alpha2().Resources(),
crires.RegistryBuilder(r.State().V1Alpha2().Resources()),
install.OptionsFromUpgradeRequest(r, in)...,
)
Inside the container the installer runs in ModeUpgrade. The crucial property: RunInstallerContainer in upgrade mode does not repartition. It writes the new kernel/initramfs (or UKI) into the inactive slot and flips the bootloader — that is the entire on-disk change. The host then reboots, optionally via kexec for a faster turnaround (kexec is skipped under SecureBoot, see below).
The disk layout is fixed at first install. "System disk wipe on upgrade is not supported anymore" — the option is logged and ignored. This is precisely why META (and STATE) survive an upgrade untouched: the upgrade path only ever writes a slot and flips a pointer.
#Revert-on-failure as a transaction
The defining safety property is this: an upgrade that boots into a kernel which never reaches a healthy state is automatically reverted to the previous slot. There is no separate journal file or two-phase-commit log to make this work — the transaction marker is a single META key, and its presence means "uncommitted".
Just before flipping the bootloader, the installer records the old slot label into the Upgrade META key. That label is the address of the fallback we can roll back to:
if mode == ModeUpgrade {
if ok, err := metaState.SetTag(ctx, metaconsts.Upgrade, previousLabel); !ok || err != nil {
return fmt.Errorf("failed to set upgrade tag: %q", previousLabel)
}
}
On the next boot, exactly one of two things resolves the transaction:
- Healthy → commit. The machine reaches
MachineStageRunningand reportsReady.DropUpgradeFallbackController(controllers/runtime/drop_upgrade_fallback.go) then deletes theUpgradetag. Removing the marker is the commit — the old slot is now just a stale fallback that the next upgrade will overwrite. - Fatal boot error → revert. If machined hits a fatal error, its
handle()path callsrevertBootloader(internal/app/machined/revert.go), which reads theUpgradeMETA tag and callsconfig.Revert(disk)to flip the bootloader default back to the recorded previous label. The node reboots into the image it came from.
There is no other state. Upgrade key set ⇒ "upgrade not yet confirmed good". Deleted on health, or consumed by revert on failure. This is the same out-of-band-key-as-flag pattern Talos uses elsewhere — minimal, durable, and impossible to get out of sync with the thing it describes.
Upgrade META key: present ⇒ uncommitted. Health deletes it (commit); a fatal boot reads it and flips back (revert).#The installer container
The installer entrypoint loads the machine config from stdin, picks ModeInstall or ModeUpgrade, and calls install.Install (cmd/installer/pkg/install/install.go). The same handful of steps run for both modes, but several are gated on mode:
- Preflight & errata — upgrade-only checks against the running system.
- Detect the bootloader. Install calls
NewAuto()(sd-boot when UEFI, else GRUB); upgrade callsProbe()to discover what is already installed; image build usesNew()for the requested kind. - Disk operations. Install verifies the disk is empty (or
FastWipes it on--zero/--force). On upgrade the disk is untouched beyond a GPT sanity check. - Create partitions — install/image only: BIOS-grub + EFI + BOOT + META + STATE + EPHEMERAL (+ optional image-cache).
- Format & populate the relevant filesystems.
- Install the bootloader —
bootloader.Installon install,bootloader.Upgradeon upgrade. - Handle META — on upgrade, stamp the previous slot label into the
Upgradekey (the revert marker shown above).
The split is clean: partitioning and formatting are install concerns and never run on upgrade. That is what keeps an upgrade reduced to "write a slot, flip a pointer, record a fallback" — small enough to be atomic and reversible.
#The imager & profiles
Where do the images themselves come from? From the imager — which is the same binary as the installer. cmd/installer/main.go dispatches on os.Args[0], so the one Go binary is both the thing that installs to a disk and the thing that builds the artifacts. Everything it produces is described by a profile.Profile: an Arch, a Platform, a SecureBoot flag, a Version, a Customization, an Input (base installer image, kernel, initramfs, and crucially SystemExtensions []ContainerAsset), and an Output.Kind:
const (
OutKindUnknown OutputKind = iota
OutKindISO // bootable ISO
OutKindImage // raw / cloud disk image
OutKindInstaller // installer container
OutKindKernel
OutKindInitramfs
OutKindUKI // Unified Kernel Image
OutKindCmdline
)
Imager.Execute (pkg/imager/imager.go) runs a fixed pipeline: handle the overlay, rebuild the initramfs with the system extensions, build the kernel cmdline, and — when SecureBoot/UKI is requested — build the UKI. Then it branches on Output.Kind to emit the artifact. The disk-image and installer kinds reuse cmd/installer/pkg/install in ModeImage — literally the same partitioning and bootloader code as "install to a real disk", just pointed at a file (with deterministic GPT/partition GUIDs so the images are reproducible).
System extensions (pkg/machinery/extensions/) are layered as squashfs. buildInitramfs packs a CPIO of the .sqsh images plus an extensions.yaml manifest and appends it, separately compressed, onto the stock initramfs.xz — the kernel reads the concatenated initramfs format natively. At runtime Talos mounts those squashfs images read-only as overlay layers. The consequence is important: extensions are baked at build time. "Adding an extension" is a rebuild followed by an upgrade — there is no live package install, ever.
Profile feeds Imager.Execute, which rebuilds the initramfs with extension squashfs and (for SecureBoot) a UKI, then emits one of ISO · disk image · installer container · UKI.#Bootloaders: GRUB vs sd-boot
The Bootloader interface (runtime/v1alpha1/bootloader/bootloader.go) is small — Install / Upgrade / Revert / KexecLoad — and has three implementations, each realizing the A/B abstraction in a different medium:
| Impl | Firmware | A/B medium | Default selector |
|---|---|---|---|
grub | BIOS + legacy UEFI | subdirectories A/B under BOOT | grub.cfg default entry |
sdboot | modern UEFI (required for SecureBoot) | two UKI files Talos-<ver>.efi on EFI | LoaderEntryDefault EFI variable |
dual | amd64 only | installs both GRUB-BIOS & sd-boot-UEFI | firmware picks; unused one cleaned on first boot |
The GRUB upgrade shows the A/B flip directly. It flips first, then writes the new slot — so even a crash mid-write leaves the still-good previous slot as the active default:
return func() error {
if err := c.flip(); err != nil { // swap Default <-> Fallback
return err
}
if err := c.generateAssets(opts); err != nil {
return err
}
return c.runGrubInstall(context.Background(), opts, efiFound)
}
sd-boot does the equivalent with UEFI primitives: it writes a new UKI file, keeps the current one as Fallback, and repoints the LoaderEntryDefault variable at the new file. Either way, Install/Upgrade return an InstallResult{PreviousLabel: c.Fallback} — and that previous label is the value stamped into the Upgrade META key for revert. The bootloader and the revert marker are wired together by that one field.
GRUB can't satisfy a measured boot, so SecureBoot forces the sd-boot/UKI path. It also disables kexec: a kexec'd kernel bypasses firmware measurement, so a SecureBoot upgrade always performs a full firmware reboot. The two A/B media also differ in what they mutate — GRUB swaps a grub.cfg default between A/B dirs, sd-boot rewrites an EFI variable pointing at one of two UKI files.
#The META partition
Everything that has to bridge a reboot lands in META — a small GPT partition labelled META that holds an ADV (auto-detected values) key/value store. (Talos actually keeps two ADV formats in the one partition: the modern talos ADV at the start and a legacy syslinux ADV at the end.) Keys are uint8 IDs defined in pkg/machinery/meta/constants.go:
const (
Upgrade = iota + 6 // fallback/revert marker (previous slot label)
StagedUpgradeImageRef // image ref for a staged upgrade
StagedUpgradeInstallOptions // JSON install.Options for staged upgrade
StateEncryptionConfig // JSON v1alpha1.Encryption (disk encryption)
MetalNetworkPlatformConfig
DownloadURLCode
UserReserved1
UserReserved2
UserReserved3
UUIDOverride
UniqueMachineToken
DiskImageBootloader // wiped on first boot
)
Values are k=v pairs, ;-joined and base64-encoded (gzip'd over 256 bytes) so the whole store can also be passed at boot via a META= kernel argument. The ReloadMeta task loads the partition and syncs each tag into a MetaKey COSI resource, so the rest of the system reads META as ordinary COSI resources rather than poking at raw bytes.
The reason META can hold a revert marker or a staged-upgrade ref across a reboot is structural: META is a distinct partition from STATE/EPHEMERAL, and the installer never repartitions on upgrade. Even a node reset is surgical — when STATE is wiped but META is not, the code removes only the StateEncryptionConfig tag and leaves the rest. That persistence is exactly what lets the staged-upgrade handoff and the auto-revert fallback marker survive the very reboot they exist to span.
Upgrade
The fallback/revert marker — previous slot label. Set by the installer, deleted on health, read on fatal boot. Its presence is the upgrade transaction.
StagedUpgradeImageRef
Image ref (+ StagedUpgradeInstallOptions) for an upgrade deferred to the next boot's Install sequence.
StateEncryptionConfig
Disk-encryption config for STATE, stashed in META so an encrypted STATE can be unlocked on first boot before its own config is readable.
Put together, the picture is a single coherent design: the OS is an immutable image; an upgrade boots a different one into the inactive A/B slot; the code that performs it ships with the version you are moving to; the whole thing is a transaction whose only marker is a META key that auto-reverts you if the new image never goes healthy; and the imager that built that image is the very same binary, run in a different mode. With every subsystem now on the table — boot, runtime, config, storage, networking, Kubernetes, the API, trust, and lifecycle — Chapter 10 — The Whole Machine steps back to assemble them into one end-to-end mental model of a Talos node.