Consulting and Craft · Hands On

Building Raspberry Pi Images on a Mac With Ansible

· 11 min read

A Pi you configure after it boots has already been on a network with a default image, and for some cards that is the one thing you cannot allow. Build the image instead: mount it on the Mac, run Ansible against it as if it were a running Pi, and write a card that comes up finished. The playbooks are the same ones you would run against the live machine, so the image is code you can review, rerun and diff, and a rebuilt card is configured the same way as the last one.

The trick is Ansible’s community.general.chroot connection. It runs every task inside a directory on the controller as if that directory were the root of another machine, so apt, copy, template and systemd_service all work on the mounted image. The image is arm64; on an Apple silicon Mac, so is the Linux VM, so the image’s own binaries run natively and nothing is emulated.

The build VM

Any Linux VM will do. Lima builds one matching the Mac’s own architecture unless told otherwise, so on Apple silicon it is arm64 and runs under Apple’s Virtualization framework:

limactl start --name=pibuild template:debian
limactl shell pibuild
sudo apt-get update && sudo apt-get install -y ansible parted xz-utils

Debian’s ansible package is the full community bundle, so the community.general collection comes with it. On an Intel Mac the VM is x86, so add qemu-user-static to that install; it registers the arm64 binary handler so the image’s programs run under emulation, slower but otherwise the same.

Work on the VM’s own disk rather than a directory shared from the Mac, which Lima mounts read-only by default. Fetch the image and its checksum, and check one against the other before anything else touches it:

mkdir -p /var/tmp/pi && cd /var/tmp/pi
url=$(curl -sI https://downloads.raspberrypi.com/raspios_lite_arm64_latest |
      awk 'tolower($1) == "location:" {print $2}' | tr -d '\r')
curl -L -O "$url" -O "$url.sha256"
sha256sum -c "$(basename "$url").sha256"
xzcat "$(basename "$url")" > pi.img

Mounting the image

The image’s root filesystem is sized to fit what ships in it, with little room to spare, because the Pi grows it to fill the card on first boot. Grow it here first, or the first apt install runs out of space. Then mount both partitions where they sit on a running Pi, and bind the VM’s /proc, /sys and /dev in so package scripts find what they expect:

#!/usr/bin/env bash
# build-image IMAGE PLAYBOOK [ansible args]: grow, mount, configure, unmount.
set -euo pipefail
img=$1 playbook=$2 root=/mnt/pi   # root must match inventory.yml

truncate -s +1G "$img"
loop=$(losetup -Pf --show "$img")
trap 'umount -R "$root" 2>/dev/null; losetup -d "$loop"' EXIT
parted -s "$loop" resizepart 2 100%
e2fsck -pf "${loop}p2"
resize2fs "${loop}p2"

mkdir -p "$root"
mount "${loop}p2" "$root"
mount "${loop}p1" "$root/boot/firmware"
for d in proc sys dev dev/pts; do mount --bind "/$d" "$root/$d"; done

ansible-playbook -i inventory.yml "$playbook" "${@:3}"

The trap unmounts everything and releases the loop device however the run ends, so a failed task leaves the image consistent and the next run starts clean.

The inventory

The inventory host is the mount point, and the connection is the chroot:

images:
  hosts:
    /mnt/pi:
  vars:
    ansible_connection: community.general.chroot
    image_build: true

The chroot connection runs only as root, so the whole build runs under sudo. The images group carries image_build: true, and that one variable is what lets the same roles serve a live Pi and an image.

What a chroot is not

A chroot changes the filesystem a process sees, and nothing else. It shares the build VM’s kernel, and nothing is booted inside it. Most tasks never notice. Some do, and they fall into two groups.

Starting services does nothing. systemctl enable works, because enabling a unit only writes symlinks into /etc/systemd/system. Starting one fails, because there is no running systemd to ask: systemctl start exits with an error saying the system was not booted with systemd. Ansible’s systemd_service module detects the chroot, warns, and carries on rather than failing the play. Package installs try to start services too, and the playbook below stops them with a policy-rc.d that tells Debian’s maintainer scripts not to start anything while the image is being built.

Anything that talks to the kernel talks to the VM’s. nft -f loads its ruleset into the build VM’s firewall, sysctl -p changes the VM’s kernel parameters, and modprobe loads modules into the VM. None of that reaches the image. A firewall role whose handler reloads the rules, run against an image, firewalls the build VM, which is exactly the handler you most want to skip.

So the roles say which tasks belong to a live machine:

# roles/offline/handlers/main.yml
- name: Load firewall rules
  ansible.builtin.command: nft -f /etc/nftables.conf
  when: not (image_build | default(false))

Against a live Pi, image_build is unset, the rules change and the handler loads them. Against an image, the file lands and the service is enabled, and the Pi loads the rules itself when it boots.

The playbook

Here the example is a card that must never touch a network: the image is the only chance to install software on it, so everything happens while the build VM can still download, and then everything that could bring an interface up goes:

# image.yml
- hosts: images
  pre_tasks:
    - name: Lend the chroot the build VM's DNS
      ansible.builtin.copy:
        src: /etc/resolv.conf
        dest: /etc/resolv.conf
    - name: Stop package scripts starting services
      ansible.builtin.copy:
        dest: /usr/sbin/policy-rc.d
        content: "#!/bin/sh\nexit 101\n"
        mode: "0755"
  roles:
    - offline
  post_tasks:
    - name: Leave nothing of the build behind
      ansible.builtin.file:
        path: "{{ item }}"
        state: absent
      loop:
        - /usr/sbin/policy-rc.d
        - /etc/resolv.conf
        - /var/lib/apt/lists
    - name: Give the card a fresh machine-id on first boot
      ansible.builtin.copy:
        dest: /etc/machine-id
        content: ""
# roles/offline/tasks/main.yml
- name: Install what the card needs while it can still download
  ansible.builtin.apt:
    name: [openssl, nftables]
    update_cache: true

- name: Remove everything that could bring an interface up
  ansible.builtin.apt:
    name: [wpasupplicant, network-manager, isc-dhcp-client, openssh-server, avahi-daemon]
    state: absent
    purge: true
    autoremove: true

- name: Blacklist the wired, wireless and Bluetooth drivers
  ansible.builtin.copy:
    dest: /etc/modprobe.d/no-network.conf
    content: |
      blacklist brcmfmac
      blacklist bcmgenet
      blacklist smsc95xx
      blacklist lan78xx
      blacklist macb
      blacklist btbcm

- name: Turn the radios off in firmware
  ansible.builtin.blockinfile:
    path: /boot/firmware/config.txt
    block: |
      dtoverlay=disable-wifi
      dtoverlay=disable-bt

- name: Set the operator account so first boot runs no wizard
  ansible.builtin.copy:
    dest: /boot/firmware/userconf.txt
    content: "operator:{{ operator_password_hash }}\n"
    mode: "0600"

- name: Hold default-deny firewall rules
  ansible.builtin.copy:
    src: nftables.conf
    dest: /etc/nftables.conf
  notify: Load firewall rules

- name: Load them at boot
  ansible.builtin.systemd_service:
    name: nftables.service
    enabled: true

The drivers cover Ethernet from the Pi 3 (smsc95xx, lan78xx) through the Pi 4 (bcmgenet) and Pi 5 (macb), and on a Pi 5 the firmware swaps in the Pi 5 versions of both radio overlays itself. The image is the 64-bit one, so the card is for a Pi 3 or later.

userconf.txt on the boot partition is how Raspberry Pi OS takes its first user without a setup wizard; openssl passwd -6 prompts for a password and prints the hash it wants. Build:

sudo ./build-image pi.img image.yml -e "operator_password_hash=$(openssl passwd -6)"

The run reports the firewall handler as skipped, which is the variable doing its job. Run against this image, apt finds openssl and nftables already present on Raspberry Pi OS Lite and changes nothing, and the purge removes the rest.

Writing the card

Copy the image back to the Mac, record its checksum, and write it to the card. diskutil list names the card; check it twice, because dd writes to whichever disk you give it:

limactl copy pibuild:/var/tmp/pi/pi.img .
shasum -a 256 pi.img | tee pi.img.sha256
diskutil unmountDisk /dev/diskN
sudo dd if=pi.img of=/dev/rdiskN bs=4m

rdiskN is the raw device, which writes faster than diskN. The checksum is the record of what you built, and a card that matters can be read back and compared against it before you trust it:

sudo dd if=/dev/rdiskN bs=4m count=$(( $(stat -f%z pi.img) / 4194304 )) | shasum -a 256

That reads back only as many bytes as the image holds, since the card is larger than the image. It assumes the image is a whole number of 4 MiB blocks; echo $(( $(stat -f%z pi.img) % 4194304 )) printing 0 confirms it, as it does for current Raspberry Pi OS Lite plus the 1 GiB added above. A rebuild from the same playbook does not produce the same checksum, because apt fetches whatever is current and every file carries the time it was written; the checksum identifies this build, not the recipe.

Next: a two-tier offline CA, whose root lives on a card built this way

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.