Infrastructure as code (IaC) configuration for my homelab setup.
Currently hosted on a Dell XPS 9370 with a SONOFF Zigbee Dongle-P adapter. Mostly managed with Pulumi, via the Kubernetes provider.
Automatically deployed via GitHub CI.
Add a block to appDefinitions.ts.
Useful e.g. to get an MCP server running npx -y <package> to pick up the latest published version, without needing kubectl access.
Run the Restart service workflow from the Actions tab, or:
gh workflow run restart.yaml -f service=google-drive-mcpThe service name is the name from appDefinitions.ts.
The backup CronJob (backup.ts) runs nightly at 03:30 Europe/London. It tars and gzips the Home Assistant and Zigbee2MQTT volumes, encrypts the archive with age, and uploads it to homelab-backups/ in Google Drive using rclone. Retention keeps the newest 7 in daily/, 4 Monday copies in weekly/ and 6 copies from the 1st in monthly/. HA's own backups/, tts/, deps/, .cache/ and logs are left out, and an archive is about 60 MB.
Check on it with kubectl get cronjob,jobs and kubectl logs job/<name>. A failed run shows as a failed Job.
Each successful run calls a Home Assistant webhook, which sets input_datetime.last_homelab_backup. The "Homelab backup overdue" automation checks it at 09:00 and notifies the Pixel 9 Pro if it's more than 26 hours old, so you hear about any failure, including the job not running at all. Both automations and the helper are managed in the HA UI, not here.
These go in homelab/prod.ts in domdomegg/secrets, which is the PROD_ENV GitHub secret. While any is empty, the job fails.
BACKUP_AGE_RECIPIENT: runage-keygen -o homelab-backup-key.txt, then use thePublic key: age1...it prints. Keephomelab-backup-key.txtindomdomegg/secrets. It's the only way to decrypt the backups, and it never goes into the cluster.BACKUP_RCLONE_DRIVE_TOKEN: a refresh token for the Drive owner from the Google MCP servers' OAuth client (GOOGLE_MCP_CLIENT_ID), with thedrive.filescope, which only lets it see files rclone created. rclone's own client is being retired. The MCP client has no loopback redirect URI, so either addhttp://127.0.0.1:53682/to it in the Cloud Console and run the commands below, or run the OAuth flow by hand through a Google MCP wrapper's/callback, which forwards codes to loopback addresses. Then use the JSON token.rclone config create homelab-backup drive scope=drive.file client_id=... client_secret=... rclone config show homelab-backup | sed -n 's/^token = //p'
BACKUP_HA_WEBHOOK_ID: the webhook ID on the "Homelab backup succeeded" HA automation.
- Download a backup, either from the Drive web UI or with
rclone copy homelab-backup:homelab-backups/daily/homelab-backup-YYYY-MM-DD.tar.gz.age . - Optionally inspect it with
age -d -i homelab-backup-key.txt homelab-backup-YYYY-MM-DD.tar.gz.age | tar -tz. It containsha/(HA's/config) andzigbee2mqtt/(Z2M's/app/data). - Stop the apps and start a pod that mounts both volumes:
kubectl scale deploy/ha-deployment deploy/zigbee2mqtt-deployment --replicas=0 kubectl run restore --image=busybox --restart=Never --overrides='{"spec":{"containers":[{"name":"restore","image":"busybox","command":["sleep","3600"],"volumeMounts":[{"name":"ha","mountPath":"/volumes/ha"},{"name":"z2m","mountPath":"/volumes/zigbee2mqtt"}]}],"volumes":[{"name":"ha","persistentVolumeClaim":{"claimName":"ha-data-pvc"}},{"name":"z2m","persistentVolumeClaim":{"claimName":"zigbee2mqtt-data-pvc"}}]}}' kubectl wait --for=condition=Ready pod/restore
- Extract the archive onto the volumes. To roll back a volume rather than fill an empty one, clear it first, e.g.
kubectl exec restore -- find /volumes/zigbee2mqtt -mindepth 1 -delete.age -d -i homelab-backup-key.txt homelab-backup-YYYY-MM-DD.tar.gz.age | kubectl exec -i restore -- tar -xz -C /volumes
- Clean up and start the apps again:
kubectl delete pod restore kubectl scale deploy/ha-deployment deploy/zigbee2mqtt-deployment --replicas=1
This is also how to set up a fresh volume. HACS lives in custom_components/, which the backup includes, and HA's configuration.yaml comes from its ConfigMap.
- Install Node.js
- Install Pulumi CLI
- Get access keys to domdomegg AWS account
- Create
~/.aws/configwith the contents:[default] region = eu-west-1
- Create
~/.aws/credentialsfile with the contents:[default] aws_access_key_id = <ACCESS_KEY_ID_FOR_DOMDOMEGG_ACCOUNT> aws_secret_access_key = <ACCESS_KEY_ID_FOR_DOMDOMEGG_ACCOUNT>
- Run
pulumi login s3://domdomegg-pulumi-backend/homelab - See step in manual setup notes below to get kubeconfig
- Deploy with
npm run deploy:prod
home.adamjones.me has two very different faces:
- AAAA record β the XPS directly. Works only from networks with IPv6.
- A record β the Oracle Cloud relay (130.162.187.38) running
ipv6-proxy, which forwards
only ports 80 and 443 to the XPS. Port 22 on the IPv4 address is the
relay's own sshd β this is why
ssh xpsfrom an IPv4-only network fails with a scary host-key-changed warning: you're talking to the wrong machine, not being MITM'd (probably).
From an IPv4-only network:
- SSH to the XPS: doesn't work even via the relay as a jump host β the
router firewall only pinholes 80/443/6443, and port 22 gets refused.
Fix by adding a router pinhole, or use the kubectl route below and a
privileged pod with
nsenter -t 1 -m -u -i -nfor host access. - kubectl: port 6443 is pinholed, so tunnel through the relay:
(
ssh -f -N -L "16443:[$(dig +short AAAA home.adamjones.me)]:6443" ubuntu@130.162.187.38 kubectl --server=https://127.0.0.1:16443 --tls-server-name=localhost get nodes--tls-server-name=localhostbecause the apiserver cert's SANs don't include home.adamjones.me.) - Pulumi deploys work over the same tunnel: write the server override
into a copy of your kubeconfig and run
KUBECONFIG=<copy> npm run deploy:prod.
To get a kubernetes cluster that you can run this all on, we install Ubuntu server + k3s. Full instructions:
- Download the Ubuntu Server
.iso - Install Ventoy onto a USB stick, and copy over Ubuntu Server
- Run the Ubuntu Server installer via Ventoy, and during installation choose:
- Connect to internet
- Enable OpenSSH server
- Only allow connecting via SSH keys
- Can use the GitHub keys temporarily, we'll replace them in a second
- (recommended) SSH in to the machine remotely - generally easier to copy and paste the next bits from a machine with easy access to these instructions. The following instructions assume you've created an SSH config entry called
xpsto connect, i.e. you can dossh xps. - Replace SSH keys with the key you want to use. Adam: for
id_ed25519_xps:echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC6fJP2IaW0o1y3xT2SwwNjT8zC3V4CpNCGYYVEY3eVv" > ~/.ssh/authorized_keys
- Enable sudo without password
In the file, change the %sudo line to:
sudo visudo
(Why: Avoids needing to manage a separate sudo password. Also see this security discussion)%sudo ALL=(ALL:ALL) NOPASSWD: ALL - Harden security by setting password to random value, and then locking it to disable its use, then disabling getty.
NB: this will make the device only accessible via SSH
echo $USER:$(openssl rand -base64 24) | chpasswd sudo passwd -l $USER sudo systemctl mask getty@.service
- Expand the storage space to the whole disk (by default only 100GB is used)
sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
- Set the display to blank after 30s of inactivity, by editing
/etc/default/grubto set:then apply the changes to the configuration file with:GRUB_CMDLINE_LINUX_DEFAULT="consoleblank=30"
sudo update-grub
- (for laptops) Make closing the lid not put it to sleep, by editing
/etc/systemd/logind.confto add:
HandleLidSwitch=ignore
LidSwitchIgnoreInhibited=noThen restart systemd with:
sudo service systemd-logind restart-
Install k3s Create /etc/systemd/system/shutdown-k3s.service:
# From https://github.com/k3s-io/k3s/issues/2400#issuecomment-1312621468 [Unit] Description=Kill containerd-shims on shutdown DefaultDependencies=false Before=shutdown.target umount.target [Service] ExecStart=/usr/local/bin/k3s-killall.sh Type=oneshot [Install] WantedBy=shutdown.target
All k3s server settings live in
/etc/rancher/k3s/config.yaml, so the installer never needs flags (see Upgrading k3s below). Create it before installing:node-ip: <node's public IPv6>,<node's LAN IPv4> kube-apiserver-arg: # Let GitHub Actions authenticate with OIDC tokens (see src/k8s/ciRbac.ts) - authentication-config=/etc/rancher/k3s/github-oidc-auth.yaml # Let AWS federate against service account tokens (see src/aws/clusterOidcProvider.ts) - service-account-issuer=https://k8s-oidc.home.adamjones.me - service-account-issuer=https://kubernetes.default.svc.cluster.local disable: - traefik flannel-ipv6-masq: true cluster-cidr: fd7d:4ce0:5b35:1::/64,10.42.0.0/16 service-cidr: fd7d:4ce0:5b35:2::/112,10.43.0.0/16
And
/etc/rancher/k3s/github-oidc-auth.yaml:apiVersion: apiserver.config.k8s.io/v1 kind: AuthenticationConfiguration jwt: - issuer: url: https://token.actions.githubusercontent.com audiences: - homelab-k8s claimMappings: username: # Map the GitHub token subject to a k8s username. Only the exact # repo+branch below is granted RBAC; other subs authenticate but have # no permissions. claim: sub prefix: "github:" claimValidationRules: - claim: sub requiredValue: "repo:domdomegg/homelab:ref:refs/heads/master"
Then run:
curl -sfL https://get.k3s.io | sh -s - sudo systemctl enable shutdown-k3s.service
Cluster CIDR range can be generated with https://unique-local-ipv6.com/ (replace
fd7d:4ce0:5b35with the part you've generated here). The CIDRs can't be changed after the cluster is created. We disable the bundled Traefik so we can install our own via Helm, version-pinned and configured intraefik.ts. -
Add the host firewall. It blocks just two ports from outside the machine:
8472/udp(flannel VXLAN, which is unauthenticated, so anyone who can reach it can inject packets into the pod network) and10250/tcp(the kubelet API). Neither is needed from outside on a single-node cluster. Everything else (SSH, 80/443 via Traefik, 6443 for CI, Home Assistant's host-network ports) is deliberately left open as before, and traffic from loopback, the k3s interfaces and the pod/service CIDRs is still allowed (e.g. metrics-server scrapes the kubelet from a pod).The rules are a dedicated nftables table,
host/hostfw.nft, loaded at boot byhost/hostfw.service. This doesn't use ufw (which fights k3s's iptables rules) or the stocknftables.service(its/etc/nftables.confrunsflush ruleset, which would wipe k3s's rules).From a checkout of this repo:
scp host/hostfw.nft host/hostfw.service xps:/tmp/
Then on the XPS:
sudo install -m 644 /tmp/hostfw.nft /etc/hostfw.nft sudo install -m 644 /tmp/hostfw.service /etc/systemd/system/hostfw.service sudo systemctl daemon-reload # Safety net: undo it in 5 minutes unless cancelled sudo systemd-run --unit=hostfw-rollback --on-active=5min sh -c 'nft delete table inet hostfw; systemctl disable hostfw.service' sudo systemctl enable --now hostfw.service
Keep this session open, and check
ssh xpsandkubectl get nodesstill work from a new terminal. Then cancel the rollback:sudo systemctl stop hostfw-rollback.timer
To change the rules later, edit
/etc/hostfw.nftand runsudo systemctl reload hostfw. -
Set up Bluetooth for Home Assistant (see official docs). In short on my device it was:
sudo apt install -y bluez # nb: may not be necessary after Ubuntu 23.10 # https://www.phoronix.com/news/Ubuntu-23.10-Dbus-Broker-Plan sudo apt install dbus-broker -y sudo systemctl stop dbus sudo systemctl disable dbus sudo systemctl start dbus-broker sudo systemctl enable dbus-broker
-
Get the kubeconfig.
Find it in
/etc/rancher/k3s/k3s.yaml. You might want to tweak theserver:line to set the remote address, and potentially addtls-server-name: localhostto avoid certificate warnings (this matches the flag used in the remote access section above).Or, on the external system you want to get the config, run:
# NB: this will overwrite ~/.kube/config mkdir -p ~/.kube && ssh -t xps 'sudo cat /etc/rancher/k3s/k3s.yaml | sed "s/\[::1]/\[$(ip route get 2606:4700:4700::1111 | awk '\''{print $11}'\'')]/g"' > ~/.kube/config
This will substitute in the IP the system uses to reach the internet as the address to connect with kubectl.
k3s does not auto-update, so it needs upgrading manually (an EOL, internet-facing apiserver is a real risk). Kubernetes only supports one minor version at a time, so walk it up one minor per hop (e.g. v1.33.x β v1.34.x β β¦), verifying health between each:
# On the XPS. All settings live in config.yaml, so the upgrade command takes no flags:
curl -sfL https://get.k3s.io | sudo INSTALL_K3S_VERSION=v1.34.9+k3s1 sh -s -After each hop verify: kubectl get nodes is Ready, kubectl get servicecidr shows the configured ranges (fd7d:4ce0:5b35:2::/112 + 10.43.0.0/16, not a fd00:... default), the k3s log is free of not within any service CIDR errors, and pods return to Running. Latest per-minor version tags: curl -s https://update.k3s.io/v1-release/channels.
Two gotchas, both learned the hard way:
- Never pass server flags to the installer. Re-running it regenerates the systemd unit from only the flags given on that run, so flags from an earlier install silently disappear. Keep every setting in
config.yamlinstead. - Keep the cluster and service CIDRs exactly as the cluster was created. k3s needs them on every start (via
config.yaml). If they go missing or change, k3s falls back to defaults that don't match existing Services, and on 1.33+ the ServiceCIDR controller rejects every Service (not within any service CIDR) and crash-loops the apiserver. If they're set in bothconfig.yamland the unit'sExecStart, k3s concatenates them (--service-cluster-ip-range must not contain more than two entries) and refuses to start.