Setting up a Homelab: Step 1
I am building a Kubernetes cluster out of second-hand Lenovo ThinkCenter machines. As part of this project, I want to streamline the setup as much as possible, and I'll try to automate most steps. The goal is to have a reproducible setup that will simplify modifications, adding more hardware, etc.
In this post, I will go over what I call the "bootstrap step." How do you start with a fresh install of an OS, as easily as possible without having to "manually" install it from a USB stick on each machine? The solution is to create a pre-configured NVMe drive, from a cloud OS image, and set up cloud init to do the configuration that we need.
Theory of Operation
I am running this process on macOS. This comes with a few extra complications, compared to running this from a Linux machine1. Should this be run from Linux, as it makes life easier? Probably, but where's the fun in that.
Before I dive into the details, let's look at what needs to be done:
First, we need to write the image on the NVMe drive. That's easy enough. However, I want the machines to be headless, so I don't have any inputs or output connected. Ideally when they boot, they are accessible from the network, and ready to set up. This is where cloud-init comes in.
Cloud-init runs on a cloud VM during first boot, to handle machine-specific configuration. That's our exact use case! It reads its configuration from a fairly straightforward yaml configuration format. While we aren't running in "the cloud," the abstraction makes sense, and cloud-init is flexible enough to work on a bare-metal machine.
The goal of the setup is to do 2 things:
- Set up SSH key for remote access
- Set up a hostname, so we can easily find it on the network using DNS.
Step 1: Writing the image
First, we need to choose an OS that we want to run. For my setup I chose to run Ubuntu 25.10, the latest at the time of writing. You can find releases on this page.
Once we have that, we can't write that directly to the NVMe drive. They are in the QEMU Qcow2 image format (they are designed for cloud use). We'll need to convert them to raw. We'll do this with qemu2.
qemu-img convert -f qcow2 -O raw ubuntu-25.10-server-cloudimg-amd64.img ubuntu-25.10-server-cloudimg-amd64.raw
Now we have an image we can write to the NVMe drive. We'll use Ansible to automate the whole process, so it's easily repeatable.
Doing any of these following steps on the wrong disk WILL wipe out that disk, and you will lose any and all stored data! Double check all paths, and your assumptions! Proceed at your own risk!
We'll assume that the following variables are defined:
target_disk: /dev/disk2- the buffered block device that our NVMe is mounted totarget_raw_disk: /dev/rdisk2- the unbuffered block device for faster writes usingdd
First, we need to make sure that the drive is unmounted and ready to be written to:
- name: Unmount disk
ansible.builtin.command:
cmd: diskutil unmountDisk {{ target_disk }}
Then we can copy the image to the NVMe drive:
- name: Write image to disk
ansible.builtin.command:
cmd: dd if={{ image_src }} of={{ target_raw_disk }} bs=4m
We now have an NVMe drive that will boot on our machine. However, it doesn't have the configuration needed for headless remote access. So we'll have to tackle that next, and this is where cloud-init comes into play.
Step 2: Cloud-init configuration
Cloud-init has different ways in which it can get its initial scripts.
On a cloud machine, it's usually through some kind of metadata service.
For example, on AWS it's from http://169.254.169.254/latest/.
However, this isn't a cloud machine, so we'll have to do something different.
Luckily, Cloud-init supports the NoCloud datasource.
This allows us to get the user data from a specific partition called cidata or CIDATA.
This is the option we'll go with next3.
Creating the partition
Since I'm managing a Linux file system, I decided to use gptfdisk4 for all the partition management.
First, we need to make sure that we see the whole disk, otherwise we'll have no new space to create the new partition.
- name: Make GPT see the full disk
ansible.builtin.command:
cmd: sgdisk --move-second-header {{ target_disk }}
The default root (/) partition will expand to fill all the available space, so we don't want to create our new partition immediately after.
This would prevent the partition from growing.
So we'll have to create the partition at the end.
For this example, I chose a size of 50MB.
This is probably overkill for the 2 tiny files we're writing, but it gives room to grow if we need to.
- name: "Add CIDATA partition at the end"
ansible.builtin.command:
cmd: sgdisk --new=0:-51M:+50M --typecode=0:0700 --change-name=0:CIDATA {{ target_disk }}
Next, we'll format it as FAT32, and mount it:
- name: "Format CIDATA to FAT32"
ansible.builtin.command:
cmd: diskutil eraseVolume MS-DOS CIDATA {{ target_disk }}s2
- name: Mount CIDATA partition
ansible.builtin.command:
cmd: diskutil mount /dev/disk2s2
At the end, if you run diskutil, you'll get something like this, if all went well.
/dev/disk2 (external, physical):
#: TYPE NAME SIZE IDENTIFIER
0: GUID_partition_scheme *256.1 GB disk2
1: BC13C2FF-59E6-4262-A352-B275FD6F7172 1.1 GB disk2s13
2: Bios Boot Partition 4.2 MB disk2s14
3: EFI UEFI 111.1 MB disk2s15
4: Linux Filesystem 254.8 GB disk2s1
5: Microsoft Basic Data CIDATA 52.8 MB disk2s2Writing the cloud-init config
Now, we're ready to write our cloud-init files. We'll need two of them:
meta-datafor setting the hostnameuser-datafor setting up SSH access.
- name: Write cloud-init meta-data
ansible.builtin.copy:
content: |
local-hostname: {{ target_hostname }}
dest: /Volumes/CIDATA/meta-data
- name: Write cloud-init user-data
ansible.builtin.copy:
content: |
#cloud-config
users:
- name: ubuntu
ssh_authorized_keys:
{% for key in public_keys -%}
- {{ key }}
{% endfor -%}
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
dest: /Volumes/CIDATA/user-data
become: true
We'll unmount, eject, and we're done.
- name: Unmount disk
ansible.builtin.command:
cmd: diskutil unmountDisk {{ target_disk }}s2
- name: Eject disk
ansible.builtin.command:
cmd: diskutil eject {{ target_disk }}
To create our new configured boot disk, all we need to do is run:
ansible-playbook playbooks/ubuntu-start.yml --ask-become-pass -e "target_hostname=machine1"
This makes setting up multiple machines for the same cluster really easy, as we only need to change the target_hostname variable when running the playbook.
Conclusion
In this post, I've detailed how to create a bootable drive for a new PC, that will boot up, and configure itself with the correct hostname and SSH keys for remote access. Using cloud-init allows us to specify a custom bootstrap step for each machine, so we can use the same "base" image in all cases. This has greatly simplified adding new machines to the cluster, or reprovisioning old ones if we replace the SSD.
To state the obvious, this would be a lot simpler if done using Linux, as we could mount the root file system, and make our changes there. In that case, using cloud-init is probably superfluous, and this post would be 1/4 of the size it is now. However, using cloud-init gives us more flexibility on what's run. More importantly, it presented a great opportunity for experimenting with a novel (to me) way of bootstrapping Linux on a bare-metal headless machine.
Appendix
The full playbook:
- name: Flash Ubuntu disk image
hosts: localhost
become: true
vars:
image_src: /tmp/ubuntu-25.10-server-cloudimg-amd64.raw
target_raw_disk: /dev/rdisk2
target_disk: /dev/disk2
target_hostname: ubuntu
public_keys:
- ssh-rsa AAAAB3N....
tasks:
- name: Check running on macOS
ansible.builtin.assert:
that: ansible_facts['os_family'] == "Darwin"
fail_msg: "This playbook can only be run on macOS"
- name: Unmount disk
ansible.builtin.command:
cmd: diskutil unmountDisk {{ target_disk }}
- name: Write image to disk
ansible.builtin.command:
cmd: dd if={{ image_src }} of={{ target_raw_disk }} bs=4m
- name: Make GPT see the full disk
ansible.builtin.command:
cmd: sgdisk --move-second-header {{ target_disk }}
- name: "Add CIDATA partition at the end"
ansible.builtin.command:
cmd: sgdisk --new=0:-51M:+50M --typecode=0:0700 --change-name=0:CIDATA {{ target_disk }}
- name: "Format CIDATA to FAT32"
ansible.builtin.command:
cmd: diskutil eraseVolume MS-DOS CIDATA {{ target_disk }}s2
- name: Mount CIDATA partition
ansible.builtin.command:
cmd: diskutil mount /dev/disk2s2
- name: Write cloud-init meta-data
ansible.builtin.copy:
content: |
local-hostname: {{ target_hostname }}
dest: /Volumes/CIDATA/meta-data
- name: Write cloud-init user-data
ansible.builtin.copy:
content: |
#cloud-config
users:
- name: ubuntu
ssh_authorized_keys:
{% for key in public_keys -%}
- {{ key }}
{% endfor -%}
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
dest: /Volumes/CIDATA/user-data
become: true
- name: Unmount disk
ansible.builtin.command:
cmd: diskutil unmountDisk {{ target_disk }}s2
- name: Eject disk
ansible.builtin.command:
cmd: diskutil eject {{ target_disk }}-
You can't reliably mount an ext4 file system, and using cloud-init gets around this limitation. ↩
-
On a Mac, you can install the right package with
brew install qemuif you're using Homebrew. On a Debian-based Linux distro, you can useapt install qemu-utils. ↩ -
There's also the option of passing in kernel arguments, and using a service on the local network. However, since mounting
ext4partitions is not a feasible option, I've decided against this approach. ↩ -
You can install it with
brew install gptfdisk↩