Command Line Only Install for Headless Raspberry Pi OS (Trixie)

We have a lot of headless Raspis on the geekfarm for monitoring and automation. It's often a pain to bring a keyboard and monitor to the Raspi, or to disconnect the Raspi to bring it to a monitor and keyboard. We want to bake enough into the initial image that it will boot up, attach to the wired or wireless network, set up an initial user, and have an ssh keys ready so we can log in over ssh without a password.

Over time, the mechanisms for pre-configuring Raspberry Pi OS have evolved. The process we were using in Bullseye didn't work in Bookworm. I spent some time figuring out a new automated install process for Bookworm, but then it stopped working in Trixie.

So, I recently spent some time looking at the Raspberry Pi Imager to figure out how it solved the problem, and I learned about the firstrun.sh script. This made the automation so much simpler.

Here's how we're doing it now.

Step 1. Download the OS

Check for the latest release here:

Raspberry Pi OS downloads – Raspberry Pi
Raspberry Pi OS (previously called Raspbian) is our official, supported operating system.

# where my raspi images live
cd ~/projects/rpi/images

# download the image
# use arm64 for raspi 4, 5, and pi zero 2w
curl -O https://downloads.raspberrypi.com/raspios_lite_arm64/images/raspios_lite_arm64-2025-12-04/2025-12-04-raspios-trixie-arm64-lite.img.xz

# verify the digest from the website
sha256sum 2025-12-04-raspios-trixie-arm64-lite.img.xz
> 681a775e20b53a9e4c7341d748a5a8cdc822039d8c67c1fd6ca35927abbe6290  2025-12-04-raspios-trixie-arm64-lite.img.xz

# extract the image
xz -d 2025-12-04-raspios-trixie-arm64-lite.img.xz

Step 2. Burn the image

Insert the SD Card and determine the device.


# check for sd card immediately after inserting
dmesg | tail
#    [1148783.447015] scsi host0: usb-storage 1-2:1.0
#    [1148784.450887] scsi 0:0:0:0: Direct-Access     Kingston UHS-II SD Reader 0004 PQ: 0 ANSI: 6
#    [1148784.747769] sd 0:0:0:0: [sda] 62333952 512-byte logical blocks: (31.9 GB/29.7 GiB)
#    [1148784.748569] sd 0:0:0:0: [sda] Write Protect is off
#    [1148784.748572] sd 0:0:0:0: [sda] Mode Sense: 21 00 00 00
#    [1148784.749313] sd 0:0:0:0: [sda] Write cache: disabled, read cache: enabled, doesn't support DPO or FUA
#    [1148784.754798]  sda: sda1 sda2
#    [1148784.755260] sd 0:0:0:0: [sda] Attached SCSI removable disk
#    [1148784.759276] sd 0:0:0:0: Attached scsi generic sg0 type 0
#    [1148785.178103] EXT4-fs (sda2): mounted filesystem 4d48c72f-b919-4823-96cc-04d8e6dcc211 r/w with ordered data mode. Quota mode: none.

# alternately check with fdisk
sudo /usr/sbin/fdisk -l
#
# example output
#
# ...snip...
#
#    Disk /dev/sda: 29.72 GiB, 31914983424 bytes, 62333952 sectors
#    Disk model: UHS-II SD Reader
#    Units: sectors of 1 * 512 = 512 bytes
#    Sector size (logical/physical): 512 bytes / 512 bytes
#    I/O size (minimum/optimal): 512 bytes / 512 bytes
#    Disklabel type: dos
#    Disk identifier: 0xa6973e7c
#
# ...snip...
#

Make sure the disks are not mounted. Mine auto-mounted, so I had to unmount them. Note the lines in the output that match your device name (the one in the example is /dev/sda)


# check to see if the disk is mounted
df -h
#
# example output:
#
#    Filesystem      Size  Used Avail Use% Mounted on
#    udev            3.9G     0  3.9G   0% /dev
#    tmpfs           807M  6.6M  800M   1% /run
#    /dev/mmcblk0p2  117G   51G   61G  46% /
#    tmpfs           4.0G  528K  4.0G   1% /dev/shm
#    tmpfs           5.0M   64K  5.0M   2% /run/lock
#    /dev/mmcblk0p1  510M   77M  434M  16% /boot/firmware
#    tmpfs           806M  192K  806M   1% /run/user/1000
#    /dev/sda1       510M   95M  416M  19% /mnt/bootfs
#    /dev/sda2        29G  1.9G   26G   7% /mnt/rootfs


# unmount any filesystems that are mounted
umount /mnt/bootfs
umount /mnt/rootfs

Now use the device path to burn the image:


# use the device path to burn the image with dd, show progress
sudo dd if=2025-12-04-raspios-trixie-arm64-lite.img of=/dev/sda status=progress bs=4M

# there was a short pause at the end
# here's the output when done
#
#    2915041280 bytes (2.9 GB, 2.7 GiB) copied, 106 s, 27.5 MB/s
#    698+0 records in
#    698+0 records out
#    2927624192 bytes (2.9 GB, 2.7 GiB) copied, 106.643 s, 27.5 MB/s
#

Step 3. Configure

After the image finishes writing, the filesystem should be automatically mounted. If not, you'll need to mount them. Then it's time for configuration.

# run the commands below as root
sudo su -

# you may need to unmount the filesystem, check 'df -h'

# remount the filesystems
mkdir -p /mnt/bootfs /mnt/rootfs
mount /dev/sda1 /mnt/bootfs
mount /dev/sda2 /mnt/rootfs

create firstrun.sh

For the configuration, we'll use the same trick that the Raspberry Pi Imager uses, which is to create a script that runs only on the initial boot. For more information about this, see my post Raspberry Pi Imager - How does it work?

First, you need to generate your encrypted password. This can be done using openssl:

openssl passwd -6 yourpassword

# example output:
#  $6$TXSl1zgRbHBXqvCy$l61Tzl9iSYXm5fSYzzgPYAxJ6.bqvoUWraOUAaIfpvAzBlEk0Gxq6q11lrvhJ38nlV/sCNp27IAFkh7RLT4oD/

# take the output and use it to set MY_PASS_ENC in the config below

For wireless, you also need to generate a pre-encrypted 32 byte hex key:

wpa_passphrase "NetworkName" "password123"

# output:
# network={
#         ssid="NetworkName"
#         #psk="password123"
#         # psk=d24fbb7c80eb5d921560fa5d4fdce9ac52797640d1b62a6d09c22ee08dc1099a
# }

# take the value of 'psk' and set it to MY_WIFI_PASS_ENC below

Next, create a file named firstrun.sh and add the following content. Replace the configuration variables at the top.

#!/bin/bash

set +e

###################################
# variables

MY_HOSTNAME="home"
MY_USER="dude"
MY_PASS_ENC='xxx'
MY_SSH_KEY="ssh-rsa xxx me@mine"
MY_WIFI_SSID="marmot"
MY_WIFI_PASS_ENC="xxx"
MY_TZ="America/Los_Angeles"

###################################

function firstrun {
    # don't run firstrun.sh on the next boot
    # do this early so we don't get into an infinite loop if the script fails
    sed -i 's| systemd.run.*||g' /boot/cmdline.txt

    ### set the hostname
    #
    echo "$MY_HOSTNAME" >/etc/hostname
    sed -i "s/127.0.1.1.*raspberrypi/127.0.1.1\t$MY_HOSTNAME/g" /etc/hosts

    # 
    #
    /usr/lib/userconf-pi/userconf $MY_USER $MY_PASS_ENC

    # install my ssh keys
    #
    install -o "$MY_USER" -m 700 -d "/home/$MY_USER/.ssh"
    install -o "$MY_USER" -m 600 <(printf "$MY_SSH_KEY\n") "/home/$MY_USER/.ssh/authorized_keys"

    # configure and enable ssh
    #
    echo 'PasswordAuthentication no' >>/etc/ssh/sshd_config
    systemctl enable ssh

    # enable sudoers access with no password
    #
    sed -i "s/^pi /$MY_USER /" /etc/sudoers.d/010_pi-nopasswd

    # wifi config using imager_custom
    #
    /usr/lib/raspberrypi-sys-mods/imager_custom set_wlan "$MY_WIFI_SSID" "$MY_WIFI_PASS_ENC" 'US'

    # update time zone
    #
    rm -f /etc/localtime
    echo "$MY_TZ" >/etc/timezone
    dpkg-reconfigure -f noninteractive tzdata

    # configure keyboard
    #
    cat >/etc/default/keyboard <<'KBEOF'
    XKBMODEL="pc105"
    XKBLAYOUT="us"
    XKBVARIANT=""
    XKBOPTIONS=""

KBEOF
    dpkg-reconfigure -f noninteractive keyboard-configuration

}

firstrun 2>&1 | tee /var/log/firstrun.log

# remove the firstrun.sh script
rm -f /boot/firmware/firstrun.sh

exit 0

I wrote this as a function to make it easy to capture all the output in a log file. The log file can be really helpful if something goes wrong since it doesn't seem that errors happening in this stage end up in the log files or journactl.

The startup.sh created by the Raspberry Pi Imager typically offers two ways to do the configuration, one that uses the Raspberry Pi OS specific tooling, and one that uses more standard linux commands and config files. When possible, I tried to lean toward using the standard linux commands and config files, since it seems like they might be better documented and less likely to change over time. But there were a couple places where I didn't have an option:

  • to configure the user - If I add the user manually with usermod and groupmod, then I still get prompted to enter the username and password on the first boot. Using /usr/lib/userconf-pi/userconf solved that problem.
  • to configure the wifi - I'm not aware of any solution other than /usr/lib/raspberrypi-sys-mods/imager_custom, since wpa_supplicant.conf was removed in Bookworm and SSID.nmconnection doesn't work in Trixie.

Install firstrun.sh

Now install the firstrun.sh file and set it to run on first boot.

# copy firstrun.sh to the bootfs filesystem
cp firstrun.sh /mnt/bootfs/

# make sure it's executable
chmod a+x /mnt/bootfs/firstrun.sh

# check for syntax errors if you recently modified the script
bash -n /mnt/bootfs/firstrun.sh

# run firstrun.sh on boot
perl -pi -e's|resize$|resize cfg80211.ieee80211_regdom=US systemd.run=/boot/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target|' /mnt/bootfs/cmdline.txt

# finally, unmount the filesystems
umount /mnt/bootfs
umount /mnt/rootfs

Step 4. Boot

Pop the sd card into the Raspi, plug in the power, and wait for it to boot it up.

If everything was set up property, the system should boot up, connect to the wifi, and ssh should be running. It should have my ssh keys loaded, so I should be able to log in without having to type in my password. I should be able to run sudo without any password. Everything should be ready to do the next stage of the build.

Give it a few minutes for the first boot since it needs to do things like generate SSH keys and resize the filesystem.

Find the IP address and verify it's up.


# if you have DHCP / DNS set up, try pinging using the hostname
ping mhostname

# alternately try pinging using mDNS
ping myhostname.local

# you might find the ip address using your router or wifi access point


Other Observations

firstrun.sh munged

I noticed some weirdness here when the firstrun.sh script ran. The script contained a line like this:

rm -f /boot/firstrun.sh

When I went to boot up, it just appeared to shut back down again. When I mounted the sdcard back on my workstation, I was surprised to find that my firstrun.sh had been modified. The 'rm' line had been replaced with this:

rm -f /boot/firmware/firstrun.sh
sed -i 's| systemd\.[^ ]*||g' /boot/firmware/cmdline.txt
exit 0

In my case, the 'rm' line had been inside a function, and whatever modified my script broke the function definition and caused a syntax error, which prevented the system from booting properly.

I haven't dug in to figure out where this is happening, but I moved the 'rm' line to the end of the script so that the search and replace doesn't cause any syntax errors.