Raspberry Pi Imager - How does it work?

The Raspberry Pi Imager is a great tool that has helped make Raspberry Pi more accessible to a wider range of users.

I'm not personally a fan of the tool. I mean no shade to anyone who uses it, but there are a few things that make it less appealing for me. For one, to use it, I have to install a 3rd party application on my machine and run it with admin permissions. I also don't love that I have to run it every time I want to build an image for a new host, and I have to manually feed in the information every time. I especially don't like that it's not well documented, so I didn't really know what it was actually doing to my images.

This is a bit frustrating for me because we run a lot of headless raspis on the geekfarm. We have an automated process for burning an image and configuring the OS. We use what we thought were the "standard" mechanisms, e.g. mount the bootfs filesystem and touch /mnt/bootfs/ssh to enable ssh. But the process for initializing a headless raspi on Raspi OS has changed a number of times over the years. With Bookworm, the old process for configuring wifi stopped working:

This is especially problematic for headless installs. If wifi isn't configured on first boot, then it's a bunch of extra pain to bring a keyboard and monitor to my raspi, or bring my raspi to a keyboard and monitor. Ideally I never want to have to do either of these things.

Clearly, somehow the Raspberry Pi Imager has solved these problems.

Initially, I assumed it was using the same mechanisms I was using, e.g. that there would be some step where it mounted the bootfs filesystem and touched /bootfs/ssh to enable ssh. But when I mounted an image created by the imager, I didn't see that file on the filesystem.

So, I decided to dig a little deeper.

Starting with the Source

To understand how the Raspberry Pi Imager solves these problems, I spent a little time looking at the source code. If I could just find how it was configuring users, wifi, and other resources, then my questions should be answered.

But I'm not a C++ developer. There appeared to be tens of thousands of lines of code in the project. From what I could tell, the code didn't seem to be well documented or structured in a way that made it obvious what was happening at the high level. I just wasn't sure how much behavior might be nestled in all that code.

I saw some references to "firstrun.sh". I'm somewhat familiar with systemd, but I didn't recall ever hearing about that file. How does that get executed? I did some searching around for information about firstrun.sh but I wasn't finding much and it wasn't answering my questions.

So, I decided to resort to a bit of simple reverse engineering.

Diff the Images

To figure out exactly what the app is doing, I built two images--one by simply burning the Trixie image file using dd, and one using the Raspberry Pi Imager. Then I mounted the filesystems that got created and copied them to my workstation.


# created image using rpi-imager from 2025-10-01-raspios-trixie
cd ~/projects/rpi-imager
rsync -av --progress --stats /media/wu/bootfs/ imager-image/bootfs/
rsync -av --progress --stats /media/wu/rootfs/ imager-image/rootfs/

# created image from dd alone
cd ~/projects/rpi/images
sudo dd if=2025-10-01-raspios-trixie-arm64-lite.img of=/dev/sda status=progress bs=4M
cd ~/projects/rpi-imager
rsync -av --progress --stats /media/wu/bootfs/ dd-image/bootfs/
rsync -av --progress --stats /media/wu/rootfs/ dd-image/rootfs/

Then I diffed them.


# look for diffs on the bootfs volume
diff -r --brief dd-image/bootfs/ imager-image/bootfs/

Files dd-image/bootfs/cmdline.txt and imager-image/bootfs/cmdline.txt differ
Only in imager-image/bootfs/: firstrun.sh

# look for diffs on the rootfs volume
diff -r --brief dd-image/rootfs/ imager-image/rootfs/
# no diffs

This is not what I was expecting. As I had noticed previously, there's no /bootfs/ssh or /bootfs/userconf.txt. There's just this 'firstrun.sh' file and a change to the cmdline.txt file. What's that about?

cmdline.txt

So, let's take a look at that cmdline.txt file changes:

diff dd-image/bootfs/cmdline.txt imager-image/bootfs/cmdline.txt
1c1
< console=serial0,115200 console=tty1 root=PARTUUID=7351b90c-02 rootfstype=ext4 fsck.repair=yes rootwait resize
---
> console=serial0,115200 console=tty1 root=PARTUUID=7351b90c-02 rootfstype=ext4 fsck.repair=yes rootwait resize cfg80211.ieee80211_regdom=US systemd.run=/boot/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target

The changes boil down to these four options:

  • cfg80211.ieee80211_regdom=US
  • systemd.run=/boot/firstrun.sh
  • systemd.run_success_action=reboot
  • systemd.unit=kernel-command-line.target

I hadn't seen these options before, but two things seem pretty obvious here– it's going to run /boot/firstrun.sh when booting up, and if that script exits with a successful exit status, the system will automatically reboot. I'm now asking myself why it doesn't get into an infinite loop running firstrun.sh and then rebooting.

As for the other options, 'systemd.unit' appears to be related to setting systemd.run to a shell script, and cfg80211.ieee80211_regdom relates to setting the default regulatory domain for wifi.

firstrun.sh

Ok, then, so what's in this magical firstrun.sh file? Here are the contents of mine, with a few potentially sensitive bits removed.


#!/bin/bash

set +e

CURRENT_HOSTNAME=`cat /etc/hostname | tr -d " \t\n\r"`
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
   /usr/lib/raspberrypi-sys-mods/imager_custom set_hostname MY-HOSTNAME-HERE
else
   echo test >/etc/hostname
   sed -i "s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tMY-HOSTNAME-HERE/g" /etc/hosts
fi
FIRSTUSER=`getent passwd 1000 | cut -d: -f1`
FIRSTUSERHOME=`getent passwd 1000 | cut -d: -f6`
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
   /usr/lib/raspberrypi-sys-mods/imager_custom enable_ssh -k 'MY-SSH-KEY-HERE'
else
   install -o "$FIRSTUSER" -m 700 -d "$FIRSTUSERHOME/.ssh"
   install -o "$FIRSTUSER" -m 600 <(printf "'MY-SSH-KEY-HERE'\n") "$FIRSTUSERHOME
/.ssh/authorized_keys"
   echo 'PasswordAuthentication no' >>/etc/ssh/sshd_config
   systemctl enable ssh
fi
if [ -f /usr/lib/userconf-pi/userconf ]; then
   /usr/lib/userconf-pi/userconf 'MY-USER-HERE' 'MY-ENC-PW-HERE'
else
   echo "$FIRSTUSER:"'MY-ENC-PW-HERE' | chpasswd -e
   if [ "$FIRSTUSER" != "MY-USER-HERE" ]; then
      usermod -l "MY-USER-HERE" "$FIRSTUSER"
      usermod -m -d "/home/MY-USER-HERE" "MY-USER-HERE"
      groupmod -n "MY-USER-HERE" "$FIRSTUSER"
      if grep -q "^autologin-user=" /etc/lightdm/lightdm.conf ; then
         sed /etc/lightdm/lightdm.conf -i -e "s/^autologin-user=.*/autologin-user=MY-USER-HERE/"
      fi
      if [ -f /etc/systemd/system/getty@tty1.service.d/autologin.conf ]; then
         sed /etc/systemd/system/getty@tty1.service.d/autologin.conf -i -e "s/$FIRSTUSER/MY-USER-HERE/"
      fi
      if [ -f /etc/sudoers.d/010_pi-nopasswd ]; then
         sed -i "s/^$FIRSTUSER /MY-USER-HERE /" /etc/sudoers.d/010_pi-nopasswd
      fi
   fi
fi
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
   /usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'swordfishhd' '8d9f7baa276bd90392efafe637c502b9b13cf9a74675b867579e1296d73e16cd' 'US'
else
cat >/etc/wpa_supplicant/wpa_supplicant.conf <<'WPAEOF'
country=US
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
ap_scan=1

update_config=1
network={
        ssid="MY-WIFI-SSID-HERE"
        psk=MY-WIFI-ENC-PW-HERE
}

WPAEOF
   chmod 600 /etc/wpa_supplicant/wpa_supplicant.conf
   rfkill unblock wifi
   for filename in /var/lib/systemd/rfkill/*:wlan ; do
       echo 0 > $filename
   done
fi
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
   /usr/lib/raspberrypi-sys-mods/imager_custom set_keymap 'us'
   /usr/lib/raspberrypi-sys-mods/imager_custom set_timezone 'America/Los_Angeles'
else
   rm -f /etc/localtime
   echo "America/Los_Angeles" >/etc/timezone
   dpkg-reconfigure -f noninteractive tzdata
cat >/etc/default/keyboard <<'KBEOF'
XKBMODEL="pc105"
XKBLAYOUT="us"
XKBVARIANT=""
XKBOPTIONS=""

KBEOF
   dpkg-reconfigure -f noninteractive keyboard-configuration
fi
rm -f /boot/firstrun.sh
sed -i 's| systemd.run.*||g' /boot/cmdline.txt
exit 0


Here's a high-level list of what's happening.

  • set the hostname - if /usr/lib/raspberrypi-sys-mods/imager_custom exists, use it to set the hostname, else update /etc/hostname and /etc/hosts
  • enable ssh - if imager_custom exists, use it to enable ssh, else enable ssh using systemctl, add the user's ssh key to their authorized_keys file, and update the sshd_config file to disable password authentication.
  • add the initial user - if imager_custom exists, use it to add the first user, else add the user using usermod and groupmod. Also update the autologin user in lightdm.conf and autologin.conf, and then update the config so sudo will not require a password for the user.
  • configure wifi - if imager_custom exists, use it to set up the wlan configuration. If not, then create the wpa_supplicant.conf file.
  • configure the keyboard - if imager_custom exists, use it to set the keyboard keymap to 'us'. If not, then create /etc/default/keyboard to configure XKBMODEL and XKBLAYOUT, and then call 'dpkg-reconfigure -f noninteractive keyboard-configuration'
  • configure the timezone - if imager_custom exists, use it to set the timezone. If not, remove /etc/localtime, add the timezone to /etc/timezone, and then call 'dpkg-reconfigure -f noninteractive tzdata'
  • remove /boot/firstrun.sh, and remove the configuration from /boot/cmdline.txt that ran it

So, it turns out that what the Raspberry Pi Imager does is much simpler than what I've been doing.

It creates one file firstrun.sh, where all the configuration happens on the initial boot of the raspi when all the filesystems are mounted and writeable. So, it can simply run commands like usermod and groupmod to set up a user rather than using the "standard" method of using /mnt/bootfs/userconf.txt, or run "rfkill unblock wifi" since there doesn't appear to be any configuration items to enable that before the system boots.

Then at the end, firstrun.sh deletes itself from the filesystem and from cmdline.txt. So, if you look at the filesystem after the first boot is done, you would never know that firstrun.sh existed. This explains my early confusion about why it doesn't get into an infinite loop running firstrun.sh and then rebooting.

While I wish there was a better mechanism to accomplish these things, admittedly part of the beauty of this approach is that it doesn't need to change every time the initialization mechanisms change--which has happened a lot over over the years.

I used this knowledge to automate my builds for Trixie.

cloud-init

According to the article below, Raspberry Pi OS is moving away from firstrun.sh to cloud-init. This is great news because cloud-init is a standard cross-platform and distribution-agnostic tool.

Raspberry Pi Aims for More Flexible OS Configuration with a Move to Cloud-Init
The old firstrun.sh script is going away, in favor of YAML-flavored configuration files for a more robust, cross-distribution approach.