geekfarm
← Back to Blog

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 a pain to bring a keyboard and monitor out into the chicken coop, but only slightly more than disconnecting all the cables and dealing with a dusty Raspi into my office. I 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 key ready so I can log in over ssh without a password.

Over time, the mechanisms for pre-configuring Raspberry Pi OS have evolved. The process I was using in Bullseye didn't work in Bookworm. I spent some time figuring out a new automated install process for Bookworm (see: Command Line Build Notes for Headless Raspberry Pi OS (bookworm)), but then it stopped working in Trixie.

So, I recently spent some time looking at the Raspberry Pi Imager (see: Raspberry Pi Imager - How does it work?) 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.

Contents

Changelog

2026.04.18: Updated for Trixie 2026-04-14.

Step 1. Download the OS

Check for the latest release here: Raspberry Pi OS downloads – Raspberry Pi

You need to choose the 32-bit or 64-bit kernel, depending on your hardware. If you attempt to boot a 64-bit kernel on a 32-bit system, the LED will blink 7 short blinks to indicate "Kernel Image Not Found".

64-bit for raspi 3, 4, 5, and pi zero 2w

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

############################
# download the 64-bit image
curl -O https://downloads.raspberrypi.com/raspios_lite_arm64/images/raspios_lite_arm64-2026-04-14/2026-04-13-raspios-trixie-arm64-lite.img.xz

# verify the digest from the website
sha256sum 2026-04-13-raspios-trixie-arm64-lite.img.xz
> 5c9caff670594eb43b68afee2a156198cb4e4f58e5dec724b4520c53c0ab5aba  2026-04-13-raspios-trixie-arm64-lite.img.xz

# extract the image
xz -d 2026-04-13-raspios-trixie-arm64-lite.img.xz

32-bit for others

#####################
# download the 32-bit image
curl -O https://downloads.raspberrypi.com/raspios_lite_armhf/images/raspios_lite_armhf-2026-04-14/2026-04-13-raspios-trixie-armhf-lite.img.xz

# verify the digest from the website
sha256sum 2026-04-13-raspios-trixie-armhf-lite.img.xz
> 1e81ddf0f43c0ee6a9de7c30e0996274a13520505d435859d7b0c1d2a67c2764  2026-04-13-raspios-trixie-arm64-lite.img.xz

# extract the image
xz -d 2026-04-13-raspios-trixie-armhf-lite.img.xz

Step 2. Burn the image

Insert the SD Card and determine the device.

# check with lsblk
sudo lsblk -f
#
# example output with SD card in 'sda' device
#
#    NAME        FSTYPE FSVER LABEL  UUID                                 #    FSAVAIL FSUSE% MOUNTPOINTS
#    sda
#    ├─sda1      vfat   FAT32 bootfs 3F92-1DB9                             413.7M    19% /media/wu/bootfs
#    └─sda2      ext4   1.0   rootfs a5343e26-ea31-47e7-a3e0-ae56c9afa37a  107.4G     3% /media/wu/rootfs
#    mmcblk0
#    ├─mmcblk0p1 vfat   FAT32 bootfs A7E6-0C92                             433.3M    15% /boot/firmware
#    └─mmcblk0p2 ext4   1.0   rootfs f6bb7216-cc28-4ec3-86d6-b391f5f31405   52.2G    50% /
#
# another example with no filesystems on sda
#
#    NAME        FSTYPE FSVER LABEL  UUID                                 FSAVAIL FSUSE% MOUNTPOINTS
#    sda
#    mmcblk0
#    ├─mmcblk0p1 vfat   FAT32 bootfs A7E6-0C92                             433.3M    15% /boot/firmware
#    └─mmcblk0p2 ext4   1.0   rootfs f6bb7216-cc28-4ec3-86d6-b391f5f31405   43.1G    58% /
#


# alternately, check with fdisk, but this may produce a lot more output
sudo fdisk -l
#
# example output
#
# ...snip...
#
#    Disk /dev/sda: 119.08 GiB, 127865454592 bytes, 249737216 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: 0x376a7be4
#
# ...snip...
#

# if all else fails, start watching dmesg and then try inserting the sd card
sudo journalctl --follow --dmesg
#
# short form: sudo journalctl -f -k
#
# example output
#
#    [15194.633677] usb 2-1: new SuperSpeed USB device number 3 using xhci_hcd
#    [15194.656519] usb 2-1: New USB device found, idVendor=11b0, idProduct=3306, bcdDevice= 0.04
#    [15194.656536] usb 2-1: New USB device strings: Mfr=3, Product=4, SerialNumber=2
#    [15194.656541] usb 2-1: Product: UHS-II SD Reader
#    [15194.656545] usb 2-1: Manufacturer: Kingston
#    [15194.656548] usb 2-1: SerialNumber: 20220100007259
#    [15194.659656] usb-storage 2-1:1.0: USB Mass Storage device detected
#    [15194.661386] scsi host0: usb-storage 2-1:1.0
#    [15195.683464] scsi 0:0:0:0: Direct-Access     Kingston UHS-II SD Reader 0004 PQ: 0 ANSI: 6
#    [15195.683976] sd 0:0:0:0: Attached scsi generic sg0 type 0
#    [15196.193285] sd 0:0:0:0: [sda] 249737216 512-byte logical blocks: (128 GB/119 GiB)
#    [15196.194181] sd 0:0:0:0: [sda] Write Protect is off
#    [15196.194186] sd 0:0:0:0: [sda] Mode Sense: 21 00 00 00
#    [15196.195075] sd 0:0:0:0: [sda] Write cache: disabled, read cache: enabled, doesn't support DPO or FUA
#    [15196.201036]  sda: sda1 sda2
#    [15196.201577] sd 0:0:0:0: [sda] Attached SCSI removable disk
#    [15197.116992] FAT-fs (sda1): Volume was not properly unmounted. Some data may be corrupt. Please run fsck.
#    [15197.261332] EXT4-fs (sda2): 1 orphan inode deleted
#    [15197.263784] EXT4-fs (sda2): recovery complete
#    [15197.272859] EXT4-fs (sda2): mounted filesystem a5343e26-ea31-47e7-a3e0-ae56c9afa37a r/w with ordered data mode. Quota mode: none.
#

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 /mnt/rootfs

Now use the device path to burn the image:

# use the device path to burn the image with dd, show progress

# 64-bit kernel
sudo dd if=2026-04-13-raspios-trixie-arm64-lite.img of=/dev/sda status=progress conv=fsync bs=4M

# 32-bit kernel
sudo dd if=2026-04-13-raspios-trixie-armhf-lite.img of=/dev/sda status=progress conv=fsync bs=4M


# here's the output when done with the 32-bit kernel
#
#    2885681152 bytes (2.9 GB, 2.7 GiB) copied, 53 s, 54.4 MB/s2910846976 bytes (2.9 GB, 2.7 GiB) copied, #    53.4094 s, 54.5 MB/s
#
#    694+0 records in
#    694+0 records out
#    2910846976 bytes (2.9 GB, 2.7 GiB) copied, 54.9889 s, 52.9 MB/s
#

Step 3. Configure

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

# run the commands below as root
sudo su -

# you may need to unmount the filesystem
# check 'df -h' to see if they are already mounted

# 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
    #
    echo "$MY_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/010_$MY_USER-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 can be found in either journactl or the log files.

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:

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
# this will produce no output if the script has no syntax errors
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 /mnt/rootfs

If you are using a larger format sd card and you see an error like dd: failed to open '/dev/sda': Read-only file system, then the tab on the side of the sd card is in the read-only position. Try sliding it to the other position and then try again.

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 myhostname

# alternately try pinging using mDNS
ping myhostname.local

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

If you were rebuilding a box you had previously logged in to over ssh, your known ssh hosts file will need to be updated. This command will remove your existing keys to clear the WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! message you'll see the first time you log in.

ssh-keygen -R myhostname

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.