A Talos node has no shell, no SSH daemon and no package manager. There is nothing to log into and nothing to configure by hand, which is exactly why a three node Kubernetes cluster comes up from three YAML files, one bootstrap call and no golden image.
The steps below install Talos Linux on Proxmox VE and turn three virtual machines into a working Kubernetes cluster: a boot image built with Image Factory, machine configuration generated by talosctl, static hostnames applied before the first boot, etcd bootstrapped on the control plane, then a NodePort workload to prove traffic reaches the pods. Every command and every block of output below came off that lab. If you want the same cluster from a general purpose distro instead, the kubeadm route on Proxmox covers that path.
Tested August 2026 on Talos Linux v1.13.8.
What the cluster needs before you start
On the control plane, etcd is the constraint. It fsyncs on every write, so it cares about storage latency and a steady clock far more than it cares about cores. Memory tracks the size of the object store plus the API server watch cache, so a cluster holding a few hundred objects sits inside 4 GB while one carrying thousands of pods and CRDs pushes past 8 GB. Workers are sized by the pods they run plus roughly 1 GB for kubelet, containerd and the Talos runtime.
Production clusters usually land at 4 to 8 GB per control plane node and 8 to 32 GB per worker. Size etcd members identically, because a quorum commits at the pace of its slowest member, and keep them on the same low latency network segment. The lab here ran 2 vCPU, 4 GB RAM and a 20 GB disk per node. That is a floor for following along, not a production recommendation.
- A Proxmox VE host with enough headroom for three VMs. Anything that runs KVM works, and so do VMware, Hyper-V, VirtualBox and bare metal.
- Three VMs on a network with DHCP and outbound internet access. Talos pulls its own installer and the Kubernetes control plane images at first boot.
- A separate Linux workstation for
talosctlandkubectl. This one ran Ubuntu 26.04.
One planning note that catches people used to other distros: nothing gets installed onto a Talos node interactively. The node boots the ISO into maintenance mode, waits on its API, and only writes to disk once a machine config arrives. That is the whole install model.
Step 1: Build a Talos boot image with Image Factory
Talos images are assembled by Image Factory from a schematic, which is a small YAML document listing the system extensions you want baked in. Running on Proxmox, the one extension worth adding up front is the QEMU guest agent, so the hypervisor can read the guest IP and issue a clean shutdown.
Create the schematic file on your workstation:
vim schematic.yaml
Add the guest agent extension:
customization:
systemExtensions:
officialExtensions:
- siderolabs/qemu-guest-agent
Post it to the factory. The response carries the schematic ID, which is a content hash, so the same YAML always returns the same ID:
curl -s -X POST --data-binary @schematic.yaml https://factory.talos.dev/schematics
The ID is the first field in the JSON reply:
{"id":"ce4c980550dd2ab1b17bbf2b08801c7eb59418eafe8f279833297925d67c7515","schematic":"customization:\n systemExtensions:\n officialExtensions:\n - siderolabs/qemu-guest-agent\n"}
Now pull the ISO onto the Proxmox host, into whichever storage holds your ISO images. Detect the current release rather than pinning a version, so the same commands keep working after the next release:
export SCHEMATIC=ce4c980550dd2ab1b17bbf2b08801c7eb59418eafe8f279833297925d67c7515
export TALOS_VERSION=$(curl -s https://api.github.com/repos/siderolabs/talos/releases/latest | grep -m1 tag_name | cut -d'"' -f4)
[ -n "${TALOS_VERSION}" ] || echo "version lookup failed, set TALOS_VERSION by hand"
echo "Building against ${TALOS_VERSION}"
cd /var/lib/vz/template/iso
curl -fL -o talos-${TALOS_VERSION}-metal-amd64.iso \
"https://factory.talos.dev/image/${SCHEMATIC}/${TALOS_VERSION}/metal-amd64.iso"
A current metal ISO is a little over 300 MB, because it carries both boot paths: GRUB with a kernel and initramfs for legacy BIOS, and a unified kernel image with systemd-boot for UEFI. The installer itself is a container image, pulled from a registry when the node writes itself to disk:
-rw-r--r-- 1 root root 324M Aug 9 10:20 talos-v1.13.8-metal-amd64.iso
Step 2: Create the Proxmox VMs
Three settings decide whether this works on the first try. Use the q35 machine type with OVMF firmware and a 4 MB EFI disk, set the CPU type to host so the guest sees the real instruction set, and attach the system disk to a plain VirtIO SCSI controller. Sidero’s own Proxmox notes call out VirtIO SCSI Single as a cause of bootstrap hangs, so leave the controller on the plain variant. Disable ballooning too, since the kubelet reads memory pressure from a moving target otherwise.
Run this on the Proxmox host to build all three VMs. The control plane gets VMID 240, the workers 241 and 242:
ISO=local:iso/talos-${TALOS_VERSION}-metal-amd64.iso
for pair in "240 talos-cp1" "241 talos-w1" "242 talos-w2"; do
set -- $pair
qm create $1 --name $2 --machine q35 --bios ovmf --ostype l26 \
--cpu host --cores 2 --sockets 1 --memory 4096 --balloon 0 \
--scsihw virtio-scsi-pci --scsi0 zfs-pool:20,cache=writethrough \
--efidisk0 zfs-pool:0,efitype=4m,pre-enrolled-keys=0 \
--net0 virtio,bridge=vmbr0 \
--ide2 ${ISO},media=cdrom \
--boot order=ide2\;scsi0 \
--agent enabled=1
done
Swap zfs-pool for whatever storage your host uses, and local:iso for the storage holding the ISO you downloaded in step 1. The boot order above keeps the CD first, which is deliberate: Talos boots the installed system once one exists, and leaving the ISO attached is what lets a wiped node fall back into maintenance mode later. Start all three:
for i in 240 241 242; do qm start $i; done
Give them a minute to reach maintenance mode. Because the guest agent was baked into the image, Proxmox can then report each node’s DHCP address without any guesswork:
for i in 240 241 242; do
printf "VM %s: " $i
qm guest cmd $i network-get-interfaces \
| grep -oE '"ip-address" : "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+"' | grep -v 127.0.0.1 | head -1
done
An answer of QEMU guest agent is not running means the node has not finished booting yet. Wait and run it again.
The three addresses that came back on this run were the ones used for the rest of the guide:
VM 240: "ip-address" : "192.168.1.139"
VM 241: "ip-address" : "192.168.1.180"
VM 242: "ip-address" : "192.168.1.168"
Step 3: Install talosctl and kubectl
Everything from here happens on the workstation. talosctl speaks the Talos API on port 50000 and is the only way in; there is no SSH fallback. Grab the current binary and put it on the path:
curl --no-progress-meter -fL -o /tmp/talosctl \
https://github.com/siderolabs/talos/releases/latest/download/talosctl-linux-amd64
sudo install -m 755 /tmp/talosctl /usr/local/bin/talosctl
talosctl version --client
The client prints its tag and build toolchain:
Client:
Tag: v1.13.8
SHA: 3de49322
Go version: go1.26.5
OS/Arch: linux/amd64
Add kubectl from the upstream stable channel, which keeps the client within one minor of whatever Talos ships:
KVER=$(curl -sL https://dl.k8s.io/release/stable.txt)
curl --no-progress-meter -fLO "https://dl.k8s.io/release/${KVER}/bin/linux/amd64/kubectl"
sudo install -m 755 kubectl /usr/local/bin/kubectl
kubectl version --client
Keep the kubectl cheat sheet nearby once the cluster is running.
Step 4: Set the shell variables the rest of the guide uses
Node addresses, the schematic ID and the cluster name each appear in several later commands, so export them once and paste the rest as is. Substitute the addresses your own VMs picked up:
export CP_IP="192.168.1.139"
export W1_IP="192.168.1.180"
export W2_IP="192.168.1.168"
export CLUSTER_NAME="talos-cfg-lab"
export SCHEMATIC="ce4c980550dd2ab1b17bbf2b08801c7eb59418eafe8f279833297925d67c7515"
export TALOS_VERSION=$(curl -s https://api.github.com/repos/siderolabs/talos/releases/latest | grep -m1 tag_name | cut -d'"' -f4)
The unauthenticated GitHub API allows 60 requests an hour per address, and a rate-limited reply leaves TALOS_VERSION empty instead of failing loudly, which later shows up as a 404 on the factory URL. Check it rather than assume it.
test -n "${TALOS_VERSION}" && echo "version ok: ${TALOS_VERSION}" || echo "set TALOS_VERSION by hand"
Confirm they are populated before running anything that writes to a node, and re-export them if you reconnect:
echo "control plane: ${CP_IP}"
echo "workers: ${W1_IP} ${W2_IP}"
echo "cluster: ${CLUSTER_NAME} on ${TALOS_VERSION}"
While the nodes sit in maintenance mode their API answers without certificates, which is enough to check what the installer will write to. Confirm the disk name before generating anything:
talosctl -n "${CP_IP}" get disks --insecure
A VirtIO SCSI disk lands on sda, which matches the default in the generated config. A VirtIO block device would show up as vda instead and the config would need editing:
NODE NAMESPACE TYPE ID VERSION SIZE READ ONLY TRANSPORT ROTATIONAL MODEL SERIAL
runtime Disk loop0 2 4.1 kB true
runtime Disk loop2 2 84 MB true
runtime Disk sda 2 22 GB false virtio true QEMU HARDDISK
runtime Disk sr0 2 339 MB true sata true QEMU DVD-ROM
Step 5: Generate the machine configuration
Generate the secret bundle first and keep it out of version control. It holds the cluster CA and the join tokens, and every future node has to be generated from the same bundle:
mkdir -p ~/talos && cd ~/talos
talosctl gen secrets -o secrets.yaml
There is one edit worth making before generating anything else. The default install image is the plain upstream installer, which contains no extensions, so a node installed with it boots to disk without the guest agent that was in the ISO. Point the install at the factory installer for your schematic instead and the extensions survive the write to disk. Create the patch:
vim patch-installer.yaml
Reference the same schematic and release you built the ISO from:
machine:
install:
image: factory.talos.dev/metal-installer/SCHEMATIC_HERE:TALOS_VERSION_HERE
Substitute the placeholders from the variables exported earlier, since a YAML file is not a shell context and will not expand them on its own:
sed -i "s|SCHEMATIC_HERE|${SCHEMATIC}|; s|TALOS_VERSION_HERE|${TALOS_VERSION}|" patch-installer.yaml
cat patch-installer.yaml
Now generate the configs. The endpoint is the control plane API on port 6443, which for a single control plane node is simply its address. A production cluster puts a load balancer or a shared VIP here instead, because the value is baked into every node’s config:
talosctl gen config --with-secrets secrets.yaml \
"${CLUSTER_NAME}" "https://${CP_IP}:6443" \
--config-patch @patch-installer.yaml \
--output-dir _out
Three files land in _out, one per machine type plus the client credentials:
generating PKI and tokens
Created _out/controlplane.yaml
Created _out/worker.yaml
Created _out/talosconfig
Step 6: Give each node a hostname before the first apply
Current Talos releases generate a machine config as multiple YAML documents, and the hostname now lives in its own HostnameConfig document rather than under machine.network. Straight out of the generator it looks like this:
---
apiVersion: v1alpha1
kind: HostnameConfig
auto: stable # A method to automatically generate a hostname for the machine.
auto: stable derives a name from the machine identity, which is why an untouched cluster comes up with nodes called talos-kby-9gv and talos-i8g-fvv. A static hostname needs auto switched off in the same document, because any other value conflicts with it. Set the name before the config is applied: the kubelet registers its Node object on first start, so renaming later leaves a stale Node behind for you to clean up.
Write one small patch per node:
for n in talos-cp1 talos-w1 talos-w2; do
printf "apiVersion: v1alpha1\nkind: HostnameConfig\nauto: off\nhostname: %s\n" "$n" > patch-hostname-$n.yaml
done
cat patch-hostname-talos-cp1.yaml
Each one is four lines, and the auto: off line is the part people miss:
apiVersion: v1alpha1
kind: HostnameConfig
auto: off
hostname: talos-cp1
Generate a config set per node, stacking the installer patch from step 5 with that node’s hostname patch:
for n in talos-cp1 talos-w1 talos-w2; do
talosctl gen config --with-secrets secrets.yaml \
"${CLUSTER_NAME}" "https://${CP_IP}:6443" \
--config-patch @patch-installer.yaml \
--config-patch @patch-hostname-$n.yaml \
--output-dir _out/$n --force
done
Validate the file each node will actually receive before pushing it anywhere. This catches a malformed document long before a node refuses it:
talosctl validate --config _out/talos-cp1/controlplane.yaml --mode metal
talosctl validate --config _out/talos-w1/worker.yaml --mode metal
talosctl validate --config _out/talos-w2/worker.yaml --mode metal
All three should come back clean:
_out/talos-cp1/controlplane.yaml is valid for metal mode
_out/talos-w1/worker.yaml is valid for metal mode
_out/talos-w2/worker.yaml is valid for metal mode
Step 7: Apply the configuration and bootstrap etcd
Applying a config is what triggers the install. Each node writes Talos to its disk, reboots into the installed system and starts waiting for the rest of the cluster. Push all three:
talosctl apply-config --insecure --nodes "${CP_IP}" --file _out/talos-cp1/controlplane.yaml
talosctl apply-config --insecure --nodes "${W1_IP}" --file _out/talos-w1/worker.yaml
talosctl apply-config --insecure --nodes "${W2_IP}" --file _out/talos-w2/worker.yaml
Point the client at the cluster. The endpoint is the node talosctl connects through, while the node setting is the machine a command actually targets, and they are separate on purpose:
export TALOSCONFIG=~/talos/_out/talos-cp1/talosconfig
talosctl config endpoint "${CP_IP}"
talosctl config node "${CP_IP}"
Bootstrap creates the etcd cluster and runs exactly once, on one control plane node, for the life of the cluster. Running it a second time is how people corrupt a working etcd. The control plane needs to finish installing and rebooting first, so give it a bounded number of attempts rather than an open loop:
for i in $(seq 1 20); do
talosctl bootstrap 2>/dev/null && echo "bootstrap accepted" && break
sleep 15
done
If twenty attempts pass without success, run talosctl bootstrap on its own and read the error. A wrong TALOSCONFIG, an unreachable node or a certificate mismatch all look identical inside a retry loop that swallows stderr.
Attach a console to any node in Proxmox while this runs and the built-in dashboard shows the service states moving to healthy, along with the hostname, cluster name and machine type:

Note the SECUREBOOT: False line. The metal ISO used here boots with Secure Boot off, which is the normal path for a homelab. Sidero publishes a separate Secure Boot image if the hardware requires it.
Step 8: Get the kubeconfig and check the cluster
Once etcd is up, the API server follows within a minute or so. Pull the admin kubeconfig straight from the control plane. The command merges into an existing file, and --force overwrites a context of the same name, so drop the flag if you already have a context you care about:
talosctl kubeconfig ~/.kube/config --force
kubectl get nodes -o wide
All three nodes register with the hostnames set in step 6, running the Kubernetes release that Talos ships by default:
NAME STATUS ROLES AGE VERSION INTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
talos-cp1 Ready control-plane 2m41s v1.36.2 192.168.1.139 Talos (v1.13.8) 6.18.42-talos (amd64) containerd://2.2.6
talos-w1 Ready <none> 2m40s v1.36.2 192.168.1.180 Talos (v1.13.8) 6.18.42-talos (amd64) containerd://2.2.6
talos-w2 Ready <none> 2m40s v1.36.2 192.168.1.168 Talos (v1.13.8) 6.18.42-talos (amd64) containerd://2.2.6
Confirm the extension survived the disk install, which is the whole reason for the installer patch in step 5:
talosctl get extensions
The agent is running on the installed system and the schematic ID is recorded alongside it, so a future upgrade can be pinned to the same image:
NODE NAMESPACE TYPE ID VERSION NAME VERSION
192.168.1.139 runtime ExtensionStatus 0 1 qemu-guest-agent 11.0.2
192.168.1.139 runtime ExtensionStatus 1 1 schematic ce4c980550dd2ab1b17bbf2b08801c7eb59418eafe8f279833297925d67c7515
Those three commands together are the whole acceptance test for the install, so they are worth keeping in one view:

The claim that there is no way in other than the API is easy to test. Port 22 is not listening on a Talos node and never will be:
ssh [email protected]
There is no daemon behind it, so the connection is refused rather than rejected on credentials:
ssh: connect to host 192.168.1.139 port 22: Connection refused
Where a normal distro would have you read logs over SSH, talosctl exposes the same information as API calls: talosctl services for service state, talosctl dmesg for the kernel ring buffer, talosctl etcd status for raft health.
Step 9: Run a workload and reach it from outside
A cluster that answers kubectl get nodes is not yet a cluster that runs anything. Create a deployment and a NodePort service:
vim nginx-demo.yaml
Three replicas is enough to see the scheduler spread pods across both workers:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-demo
spec:
replicas: 3
selector:
matchLabels:
app: nginx-demo
template:
metadata:
labels:
app: nginx-demo
spec:
containers:
- name: nginx
image: nginx:1.29-alpine
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: nginx-demo
spec:
type: NodePort
selector:
app: nginx-demo
ports:
- port: 80
targetPort: 80
nodePort: 30080
Apply it and wait for the rollout:
kubectl apply -f nginx-demo.yaml
kubectl rollout status deployment/nginx-demo --timeout=180s
Pods land on the two workers, never on the control plane, because Talos leaves the control plane tainted by default:
NAME READY STATUS RESTARTS AGE IP NODE
nginx-demo-6b4c974c77-8lvvz 1/1 Running 0 11s 10.244.2.3 talos-w1
nginx-demo-6b4c974c77-gvmvj 1/1 Running 0 11s 10.244.1.2 talos-w2
nginx-demo-6b4c974c77-hl7hr 1/1 Running 0 11s 10.244.2.2 talos-w1
The NodePort answers on every node, including the ones with no pod on them, because kube-proxy forwards internally:
curl -s -o /dev/null -w "%{http_code}\n" "http://${W1_IP}:30080/"
A clean 200 means kube-proxy, flannel and the container runtime are all doing their jobs:
200
Pair that with a wider health check, which walks etcd, the static pods, kube-proxy and CoreDNS in order and is the fastest way to find which layer is broken when something is:
talosctl health
Every line reports OK on a cluster that is genuinely ready, and the first line that does not is the layer to go and look at:

NodePort is fine for a smoke test and wrong for anything real. Add MetalLB for LoadBalancer services on a bare metal or homelab network, put Traefik in front as an ingress controller, and wire up persistent storage with Longhorn before any stateful workload goes near it. From there, canary deployments with Argo Rollouts is a natural next layer.
Errors this build hit and what fixed them
Three of the five below come from the move to multi-document machine configs, so they show up on current releases while older guides insist the opposite. The other two are ordinary operations that surprise people the first time.
JSON6902 patches are not supported for multi-document machine configuration
The generated config is several YAML documents, and JSON patch operations only work against a single document. Replace any [{"op":"add","path":...}] patch with a strategic merge patch written as plain YAML, which is what --config-patch @file.yaml expects.
static hostname is already set in v1alpha1 config
This is what you get for setting machine.network.hostname, the field older guides tell you to use. The hostname moved to its own HostnameConfig document, and the validator rejects the config when both places claim it. Patch the HostnameConfig document instead, as in step 6.
HostnameConfig: ‘auto’ and ‘hostname’ cannot be set at the same time
A merge patch adds keys, it does not remove them, so a patch carrying only hostname: leaves the generated auto: stable in place and the validator rejects the pair. The conflict is with automatic generation being active, not with the two keys coexisting: set auto: off in the same patch and the config validates, which is what step 6 does.
bootstrap is not available yet
Harmless timing. The control plane is still writing itself to disk and rebooting, so etcd has nothing to bootstrap. Wait and retry rather than reapplying the config, which is what the retry loop in step 7 does.
Warning: would violate PodSecurity “restricted:latest”
Talos enables the PodSecurity admission plugin by default and the deployment above triggers it. The exact defaults it ships are enforce: baseline with warn: restricted and audit: restricted, with the kube-system namespace exempt, so a manifest that misses the restricted profile still runs and merely warns. Manifests copied from clusters with no admission policy will produce this on every apply until they set runAsNonRoot, drop all capabilities, disable privilege escalation and pick a seccomp profile. Raising the cluster-wide enforce level to restricted means editing the admission config in the control plane machine config, while a single namespace can be tightened on its own with the usual pod-security.kubernetes.io/enforce label. Either way those manifests stop being warned about and start being rejected.
How this flow differs from the older Talos guides
Guides written around the 1.4 era still turn up in search results, and enough has moved that following them produces errors rather than a cluster. The differences that matter:
| Task | Older guides | Current releases |
|---|---|---|
| Boot image | Plain ISO from GitHub releases | Image Factory schematic, extensions baked in at build time |
| Install image | Implicit, no extensions | Factory installer per schematic, or extensions are lost on install |
| Hostname | machine.network.hostname | Its own HostnameConfig document, with auto: off |
| Config patches | JSON6902 operations | Strategic merge YAML, JSON6902 rejected on multi-doc configs |
| Disk listing | talosctl disks | talosctl get disks |
| Documentation | talos.dev versioned paths | Moved to docs.siderolabs.com |
Redoing the cluster is cheap, which is the payoff for having no state outside the config. The command below wipes a node and drops it back into maintenance mode in about a minute, ready for a fresh apply-config, and it only lands there because the ISO is still attached from step 2. It destroys everything on the node it targets, so name that node explicitly rather than trusting whatever talosctl config node currently points at:
talosctl reset --nodes "${W2_IP}" --graceful=false --reboot --wipe-mode all
That cheap rebuild is what makes Talos worth a look for anyone tired of nursing snowflake nodes, and it is a very different proposition from a k3s install on a normal distro, where the OS underneath is still yours to patch. If the whole cluster lives on one hypervisor, the Proxmox host build is the next thing worth getting right.