Containers

Install Talos Linux Kubernetes Cluster on Proxmox

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.

Original content from computingforgeeks.com - post 137986

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 talosctl and kubectl. 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:

Talos Linux console dashboard showing control plane services healthy on Proxmox

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:

kubectl get nodes output showing a three node Talos Linux Kubernetes cluster

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:

talosctl health output and an nginx NodePort test on a Talos Linux cluster

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:

TaskOlder guidesCurrent releases
Boot imagePlain ISO from GitHub releasesImage Factory schematic, extensions baked in at build time
Install imageImplicit, no extensionsFactory installer per schematic, or extensions are lost on install
Hostnamemachine.network.hostnameIts own HostnameConfig document, with auto: off
Config patchesJSON6902 operationsStrategic merge YAML, JSON6902 rejected on multi-doc configs
Disk listingtalosctl diskstalosctl get disks
Documentationtalos.dev versioned pathsMoved 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.

Keep reading

Install Docker and Run Containers on Ubuntu 24.04|22.04 Containers Install Docker and Run Containers on Ubuntu 24.04|22.04 Best UI Applications for Managing Docker Containers Containers Best UI Applications for Managing Docker Containers Install UniFi OS Server on Ubuntu 24.04 LTS Containers Install UniFi OS Server on Ubuntu 24.04 LTS Best GitOps and Argo CD Books to Read in 2026 Books Best GitOps and Argo CD Books to Read in 2026 Best Docker and Container Books to Read in 2026 Books Best Docker and Container Books to Read in 2026 Force Delete Namespace in Kubernetes Containers Force Delete Namespace in Kubernetes

Leave a Comment

Press ESC to close