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:

  1. Set up SSH key for remote access
  2. 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:

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    disk2s2

Writing the cloud-init config

Now, we're ready to write our cloud-init files. We'll need two of them:

- 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 }}
  1. You can't reliably mount an ext4 file system, and using cloud-init gets around this limitation.

  2. On a Mac, you can install the right package with brew install qemu if you're using Homebrew. On a Debian-based Linux distro, you can use apt install qemu-utils.

  3. There's also the option of passing in kernel arguments, and using a service on the local network. However, since mounting ext4 partitions is not a feasible option, I've decided against this approach.

  4. You can install it with brew install gptfdisk