Bootstrap¶
Bare disk to running cluster. Follow it in order: each step depends on the one before, and two of them cannot be undone.
Before you start¶
Install the tooling. Everything is pinned in .mise.toml, so:
mise trust && mise install
Generate the age key if you do not already have one, and back it up to NordPass before doing anything else. Every secret in this repository is encrypted to it, and it cannot be recovered.
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
age-keygen -y ~/.config/sops/age/keys.txt # the public key, goes in .sops.yaml
You also need a Cloudflare API token scoped to Zone:DNS:Edit and Zone:Zone:Read for
fobiat.dev, and a Tailscale OAuth client with devices:core and auth_keys scopes.
Use an OAuth client rather than a reusable auth key: reusable keys expire after 90 days
and take the node off the tailnet when they do.
1. The VM¶
There is a script for this, because setting up Hyper-V by hand is fiddly if you do not live in Windows. From an elevated PowerShell prompt on the Windows host:
.\scripts\hyperv\New-TalosVM.ps1
The first run needs to know which network adapter to bind the external switch to. If it
cannot work that out on its own it prints the available adapters and stops, so re-run it
with -NetAdapterName "Ethernet" or whichever it lists. Pass -Force to delete and
recreate an existing VM.
It creates the switch, downloads the right ISO, builds the VM and starts it, then tells
you the address to point talosctl at. It deliberately does not apply any Talos config.
What it sets, and why, if you would rather do it by hand:
Hyper-V, Generation 2.
| Setting | Value | Why |
|---|---|---|
| vCPU | 4 | All of them. The host is a 4c/8t Optiplex |
| Memory | 20GB, fixed | Not dynamic. Kubernetes reacts badly to memory being taken away underneath it |
| Boot disk | 100GB VHDX | |
| Data disk | 200GB VHDX | Becomes the storage class |
| Network | External virtual switch | See below. This is the one that catches people |
| Secure Boot | Off initially | |
| Automatic start | Always start | So a Windows reboot brings the cluster back unattended |
Use an external virtual switch bound to the physical NIC, not the Default Switch. The Default Switch NATs and reassigns its subnet across host reboots, which breaks the API endpoint, the gateway's load balancer address and external-dns all at once. An external switch puts the VM on the LAN with its own MAC, so give it a DHCP reservation and treat it like real hardware.
2. The image¶
Talos images are built by the Image Factory with system extensions baked in. The
schematic is talos/schematic.yaml. Uploading it returns a content-addressed ID, so the
same schematic always produces the same ID:
curl -sX POST --data-binary @talos/schematic.yaml https://factory.talos.dev/schematics
The current ID is in talos/schematic-id.txt:
4a0d65c669d46663f377e7161e50cfd570c401f26fd9e7bda34a0216b6f1922b
So the installer image is
factory.talos.dev/installer/4a0d65c669d46663f377e7161e50cfd570c401f26fd9e7bda34a0216b6f1922b:v1.13.8,
and the ISO the script downloads is the same ID under /image/.
Regenerate and re-commit the ID whenever talos/schematic.yaml changes. Renovate tracks
the Talos version in the image tag, not the schematic contents.
siderolabs/tailscale must be in the schematic. It is how you reach the node when the
cluster is broken, and adding it later means rebuilding the image and reinstalling.
3. Machine config¶
Generated by talhelper from talos/talconfig.yaml. Two separate secret files feed it,
and they hold different things:
talos/talsecret.sops.yaml, the cluster's own PKI (CA keys, the bootstrap token).talhelper gensecretwrites this once, and it stays fixed for the cluster's life.talos/talenv.sops.yaml, everything else:TS_AUTHKEYfrom the Tailscale OAuth client,CLOUDFLARE_API_TOKENfor DNS-01. Create it yourself, it does not exist in the repo yet:
cat > talos/talenv.sops.yaml <<'EOF'
TS_AUTHKEY: <tailscale OAuth client secret>
CLOUDFLARE_API_TOKEN: <cloudflare token>
EOF
task sops:encrypt -- talos/talenv.sops.yaml
For the Tailscale side: create an OAuth client in the admin console
(Settings → OAuth clients) with the auth_keys scope, and give it a tag, tag:k8s
to match talconfig.yaml. That tag has to already exist in the tailnet's ACL policy's
tagOwners, or client creation refuses it. The client secret is what goes in as
TS_AUTHKEY, used directly as an auth key rather than exchanged for one, which is why
TS_EXTRA_ARGS in talconfig.yaml carries --advertise-tags=tag:k8s, a tagged OAuth
client cannot register untagged.
For Cloudflare: a custom API token scoped to Zone:DNS:Edit and Zone:Zone:Read on
fobiat.dev only, not the account-wide template.
The settings that matter, and why:
allowSchedulingOnControlPlanes: true. Without it the single node refuses to run anything, because control planes are tainted by default.cluster.network.cni.name: noneandcluster.proxy.disabled: true. Cilium replaces both.- Install disk selected by serial or transport, never
/dev/sda. Linux device enumeration order is not stable across reboots and this is a known Talos footgun. - A
UserVolumeConfigdocument claiming the second disk. It mounts at/var/mnt/data. --bind-address=0.0.0.0on kube-scheduler and kube-controller-manager, or their metrics bind to localhost and Prometheus cannot scrape them.- An
ExtensionServiceConfigfor Tailscale carrying the OAuth credentials.
Commit the config before applying it. Rule 5: the running cluster always has a Git antecedent.
task talos:generate
task talos:apply # applies to a node in maintenance mode, then it reboots
4. Bootstrap etcd¶
task talos:bootstrap
This runs once, ever. Running it a second time creates a new empty etcd and discards
the cluster. If you are recovering rather than installing, you want
--recover-from, not this. See Restore.
5. Cilium, then Flux¶
The node stays NotReady after bootstrap because there is no CNI. That is expected.
Nothing schedules until Cilium runs, and that includes Flux's own controllers, so the
order is fixed.
task bootstrap:cilium # helmfile, with the Talos-specific settings
export GITHUB_TOKEN=$(gh auth token)
task flux:bootstrap # pushes flux-system manifests straight to main
task flux:sops-secret # the age key as a Secret, so Flux can decrypt SOPS resources
Once Flux is running it owns everything else, including Cilium's ongoing upgrades.
Two things about this step that look like they break the project's own rules, and don't:
flux bootstrapcommits and pushes tomaindirectly, no PR. Rule 6 (anything touching the cluster goes through a PR) doesn't apply here because Flux can't reconcile a PR before it exists to reconcile anything. This is the one bootstrap-only exception.- The
sops-ageSecret is created with a barekubectl apply, not through Flux. Rule 4 (if it's not in Git, it doesn't exist) doesn't apply here either: the age private key is deliberately never committed anywhere (it lives in~/.config/sops/age/keys.txtand NordPass only), so the Secret that carries it can't be GitOps-managed without defeating the point of keeping it out of Git. This is the other bootstrap-only exception, and the only Secret in the cluster that's allowed to exist this way.
6. Watch it converge¶
flux get all --all-namespaces --watch
cert-manager will issue against the Let's Encrypt staging endpoint first. Confirm a certificate is issued before switching the ClusterIssuer to production, or you will burn through the production rate limits while debugging DNS-01 propagation.
Done when¶
flux get allis cleantalosctlandkubectlboth work over Tailscale with the LAN cable unpluggedhttps://grafana.lab.fobiat.devloads with a valid certificatehttps://home.lab.fobiat.devshows Homepage- Rebooting the VM recovers without help
- Rebooting the Windows host recovers without help