Progress Log
Raspberry Pi 4 Headless First Boot
Raspberry Pi 4 Headless First Boot
Date: 2026-04-11 Type: Build Log
Context
Todd received the Raspberry Pi 4 and wanted to do a headless first-boot setup over WiFi from his Mac, with Claude Code driving everything possible from the terminal. The official build guide (docs/articles/build-01-bench-testing.md Step 3) had a single naive sentence about flashing with Pi Imager and "SSH in after a minute." Reality was significantly more involved. This log captures what actually happened and what the guides need to reflect.
What Changed
Shipped working artifact: a fully headless Raspberry Pi 4 named tickslayer, on home WiFi (Jurassic Park.Which one?), accessible from Todd's Mac via ssh tickslayer using key auth. No monitor, no keyboard, no ethernet remaining attached.
What we did, in order
Round 1 — Naive headless flash via custom.toml (FAILED)
- Identified the SD card on Todd's Mac as
/dev/disk14(64 GB, Built-In SDXC Reader, was NTFS) usingdiskutil list physicalanddiskutil info disk14. Confirmed not the Mac's main drive (disk0, 1 TB). - Downloaded latest Raspberry Pi OS Lite 64-bit from
https://downloads.raspberrypi.com/raspios_lite_arm64_latestto~/tickslayer-pi-flash/raspios-lite-arm64.img.xz(487 MB compressed → 2.8 GB decompressed viaxz -dk -T 0). - Tried
sudo dddirectly from Claude — hitsudo: a terminal is required to read the password. The harness can't pipe sudo prompts into Claude's bash sessions. Solution: had Todd runsudo dd if=...img of=/dev/rdisk14 bs=4mhimself via the!prefix in the prompt. Wrote 2,986,344,448 bytes in 88 seconds (~34 MB/s). - Wrote
custom.tomlto the FAT32bootfspartition with hostname, user, hashed password (openssl passwd -6), SSH enable, WiFi (Jurassic Park.Which one?), and locale. - Booted Pi. Filesystem expanded (so something ran), but Pi never appeared on network. ARP table never showed any Pi MAC prefix (
b8:27:eb,dc:a6:32,e4:5f:01,d8:3a:dd,2c:cf:67).
Round 2 — Switch to cloud-init mechanism (FAILED)
- Inspected bootfs after first failed boot. Found
meta-data,user-data,network-configfiles (cloud-init style) — concluded this image uses cloud-init, notcustom.toml. - Removed
custom.toml. Wrote proper cloud-inituser-data(usertodd, hostnametickslayer, SSH, locale) andnetwork-config(netplan v2 WiFi block). Addeduserconf.txtand emptysshfile as belt-and-suspenders fallbacks. - Booted Pi. Same result: silent failure, not on network.
Round 3 — Switch to eero guest network to test "is the SSID confusing wpa_supplicant?" (FAILED + DEAD END)
- Hypothesized the special characters in
Jurassic Park.Which one?(period, space, question mark) might be breaking WPA supplicant. Asked Todd to create an eero guest network (786pi/SweeperPocket786). - Updated
network-configwith new SSID. Bumpedinstance_idinmeta-dataso cloud-init would treat next boot as fresh. - Booted Pi. Pi didn't appear. Checked eero app — only Todd's Mac was on the guest network, no Pi.
- Discovered the eero gotcha: ping-sweeping
192.168.11.0/24from the Mac filled the ARP table with(incomplete)entries for every address except gateway and the Mac itself. Eero guest networks have AP isolation enabled by default and there is no toggle to disable it. This meant even if the Pi had joined, we couldn't have reached it. Worse than useless for SSH-from-Mac debugging.
Round 4 — Ethernet (WORKED, immediately)
- Got Todd to plug ethernet from Pi to eero LAN port (initially he plugged into the modem, which put the Pi on a totally separate ISP-managed network — Mac on
192.168.4.x, Pi on whatever the modem hands out, no mutual visibility). - After moving cable to eero LAN port:
ping tickslayer.localresolved to192.168.4.31immediately. SSH connected on first try. Passwordtickslayer-tempworked (souserconf.txthad created the user).
Round 5 — Ground truth from inside the Pi (ROOT CAUSE FOUND)
Once SSHed in via ethernet, ran diagnostics. Found:
rfkill listshowedphy0: Wireless LAN — Soft blocked: yes. The WiFi radio was kernel-level RFKILL'd the entire time. This is why no SSID, no network config attempt, no nothing — the radio was off.- Why was it blocked? Pi requires a WiFi country code to be set before it'll enable the radio (regulatory compliance). We never set one. Cloud-init would have set it via the locale module, but...
systemctl is-enabled cloud-init.servicereturnednot-found. Cloud-init is NOT enabled on Raspberry Pi OS Lite Bookworm by default. All ouruser-data/network-configYAML edits across rounds 2 and 3 were doing absolutely nothing. The usertoddand hostnametickslayergot created byuserconf.txt(legacy raspi-config first-run mechanism), our belt-and-suspenders fallback. Without that fallback we would have had no user at all./etc/netplan/was empty. Bookworm Pi OS uses NetworkManager directly, not netplan. Cloud-init's network-config would have been bridged into NM if cloud-init were running, but it wasn't.
Round 6 — Fix from inside (WORKED)
sudo raspi-config nonint do_wifi_country US # Set WiFi country
sudo rfkill unblock wifi # Unblock the radio
nmcli device wifi rescan # Rescan
sudo nmcli device wifi connect "Jurassic Park.Which one?" password "theonewhereleodies"
wlan0 immediately went from unavailable → disconnected → connected, picked up DHCP at 192.168.4.33, and ping -I wlan0 1.1.1.1 succeeded. Connection profile saved at /etc/NetworkManager/system-connections/Jurassic Park.Which one?.nmconnection with autoconnect=yes, so it survives reboots. Pulled ethernet, confirmed Pi still reachable via WiFi.
SSH key setup
Generated a project-specific ed25519 keypair at ~/.ssh/tickslayer_ed25519 (no passphrase, comment todd@mac -> tickslayer pi). Pushed the public key to the Pi via expect + ssh + cat >> ~/.ssh/authorized_keys. Added an SSH config alias to ~/.ssh/config:
Host tickslayer
HostName tickslayer.local
User todd
IdentityFile ~/.ssh/tickslayer_ed25519
IdentitiesOnly yes
StrictHostKeyChecking accept-new
Now ssh tickslayer Just Works from anywhere on Todd's Mac with no password prompts.
Files Modified
~/tickslayer-pi-flash/raspios-lite-arm64.img(.xz)— Downloaded Pi OS Lite image. Kept on disk for future re-flashes.~/.ssh/tickslayer_ed25519+.pub— Project SSH keypair for Pi access.~/.ssh/config— AddedHost tickslayeralias block./Volumes/bootfs/custom.toml— Created in Round 1, deleted in Round 2 (wrong mechanism for this image)./Volumes/bootfs/user-data— Wrote cloud-init user config (createdtodd, set hostname, enabled SSH, set timezone). Did nothing because cloud-init isn't enabled, but harmless./Volumes/bootfs/network-config— Wrote netplan WiFi config. Did nothing for the same reason./Volumes/bootfs/userconf.txt— Belt-and-suspenders user creation. This is the file that actually did the work. Createdtodd:tickslayer-tempvia the legacy raspi-config first-run service./Volumes/bootfs/ssh(empty) — Belt-and-suspenders SSH enable. Pi OS auto-removes this on first boot after enabling SSH./Volumes/bootfs/meta-data— Bumpedinstance_idfromrpios-imagetotickslayer-2026-04-11-guest-net. Pointless since cloud-init wasn't running, but I didn't know that yet.
On the Pi side (live changes via SSH)
/etc/wpa_supplicant/wpa_supplicant.conf—country=USset viaraspi-config nonint do_wifi_country US./etc/NetworkManager/system-connections/Jurassic Park.Which one?.nmconnection— Created bynmcli connect. WPA2-PSK, autoconnect enabled, mode 600 root.~/.ssh/authorized_keys— Added Mac's tickslayer public key.
Key Takeaways
- Raspberry Pi OS Lite Bookworm does NOT enable cloud-init by default. The
meta-data/user-data/network-configfiles exist on the boot partition but nothing processes them. This contradicts a lot of online tutorials. Don't trust cloud-init headless config on this image. custom.tomlis also not the mechanism on plain pi-gen Pi OS Lite. It's a Pi Imager-specific feature that requires Imager to write a complementaryfirstrun.shand modifycmdline.txtto invoke it. If you writecustom.tomlby hand without those, nothing happens.userconf.txtis the actually-reliable headless user creation mechanism on current Pi OS. Format:username:openssl-passwd-6-hash. Lives in the FAT32bootfspartition. Picked up by raspi-config's first-boot service. Independent of cloud-init.- Pi WiFi is RFKILL soft-blocked until a WiFi country code is set. This is regulatory compliance and applies to all current Pi OS images. If you don't set a country, the radio never comes up and
wlan0showsunavailableinnmcli. Set it withraspi-config nonint do_wifi_country US(or viawpa_supplicant.confcountry=line). - The reliable way to configure WiFi from inside a running Pi (Bookworm) is
nmcli, not netplan and not wpa_supplicant.conf.sudo nmcli device wifi connect "<SSID>" password "<PSK>"creates a persistent profile in/etc/NetworkManager/system-connections/with autoconnect on by default. SSIDs with spaces and special characters work fine in double quotes. - Eero guest networks have non-disableable AP isolation. Useless for "let me put a new device on a clean network and SSH to it from my Mac" debugging because client-to-client traffic is blocked. The
(incomplete)ARP entries across the entire/24are the smoking gun. - Plugging ethernet into the modem ≠ plugging into the router. Devices on the modem port are on a separate ISP-managed network and can't be reached from the home LAN. Always use a LAN port on the eero (or whatever router) for headless debug.
- The harness can't enter sudo passwords. For
sudo ddand similar, have the user run it themselves via the!prefix in the prompt. Pre-caching withsudo -vdoesn't help because the cache is per-tty and Claude's bash sessions don't share a tty with the user's interactive shell. networksetup -getairportnetwork en0is broken on recent macOS. Returns "not associated" even when WiFi is connected. Useifconfig en0+netstat -rnorwdutil infoinstead.expectis built into macOS and is the right tool when you need to script a single password prompt in non-interactive contexts (initial SSH key copy, etc).
Implications for the build guides
build-01-bench-testing.md Step 3 currently says "Flash Raspberry Pi OS Lite (64-bit) onto the MicroSD card using Raspberry Pi Imager... During imaging, set hostname, enable SSH, set username/password, configure WiFi" — this is fine for the GUI Imager path, but the guide has zero coverage of:
- The CLI flash path (
xz -d+dd) for users who'd rather script it - Any of the gotchas above (cloud-init not enabled, WiFi country requirement, eero guest isolation, modem-vs-router)
- The agent-tip callout pattern Todd asked for (
<agent-tip>blocks with prompts a user can copy-paste to their agent, plus links to required hardware/software) - Verification steps (
ssh tickslayer,nmcli device status,rfkill list)
build-02-pi-setup.md Step 1 assumes you've already SSHed in. We need to add a "if SSH doesn't work, here's how to debug" sub-section that walks through the rfkill / cloud-init / userconf issues we hit.
Plan for next session: retrofit Guides 1 and 2 with the lessons above, build the <agent-tip> custom React component (src/components/agent-tip.tsx) and register it in src/app/docs/markdown-renderer.tsx next to the existing <part> component, and start sprinkling agent-tip callouts throughout the guides.
Time spent
~90 min for what the existing guide implies should be 5 min. Worth it for the documented lessons — every one of these is something the next person (or future-Todd, or another agent) would also hit.
