Skip to content

A minimal Raspberry Pi 4 server image with pi-gen

Build a headless Raspberry Pi OS (trixie, arm64) image for the Raspberry Pi 4 containing only what a networked server needs, then boot it from an SD card or USB stick. Configuration — login and wifi — is supplied by editing one plain-text file on the boot partition.

Stock Desktop image This image
Packages 1649 ~305
Written size ~5 GB ~1.7 GB
/usr/share/locale 213 MB 1.2 MB
Services running at idle 19 11
RAM used at idle (1 GB board) 309 MB ~156 MB
Boot to multi-user.target 19 s ~9 s

Scope

Pi 4 only. It installs linux-image-rpi-v8 and the Broadcom wifi firmware for the Pi 4's BCM4345/6. A Pi 5 needs the 2712 kernel instead.

1. Prerequisites

Build host: any x86-64 Linux box with Docker, ~10 GB free, and a wired or otherwise reliable network. Nothing is installed system-wide; debootstrap and qemu live inside the pi-gen container.

Target: Raspberry Pi 4, plus an SD card or USB stick of 8 GB or more.

2. Enable arm64 emulation on the build host

The image is arm64 and the host is x86-64, so the kernel needs an arm64 binfmt handler:

docker run --privileged --rm tonistiigi/binfmt --install arm64

Verify:

docker run --rm --platform linux/arm64 arm64v8/debian:trixie-slim uname -m
# aarch64

build-docker.sh also checks for a qemu-aarch64 binary on the host PATH, separately from the container's own copy. Lift it out of the image rather than installing packages on the host:

cid=$(docker create pi-gen 2>/dev/null || docker create arm64v8/debian:trixie-slim)
docker cp "$cid":/usr/bin/qemu-aarch64 /tmp/qemu-aarch64 2>/dev/null
docker rm -f "$cid"
sudo install -m 755 /tmp/qemu-aarch64 /usr/local/bin/qemu-aarch64

Note

The binfmt registration is kernel state and is lost on reboot. Re-run the tonistiigi/binfmt command after a host restart.

3. Get pi-gen

64-bit images come from the arm64 branch; master hardcodes ARCH=armhf.

mkdir -p ~/build && cd ~/build
git clone -b arm64 https://github.com/RPi-Distro/pi-gen.git
cd pi-gen

4. Install only the Pi 4 kernel

Stock stage0 installs the Pi 5 2712 kernel and headers for both kernels.

cat > stage0/02-firmware/01-packages <<'EOF'
initramfs-tools
raspi-firmware
linux-image-rpi-v8
EOF

5. Skip stages 2–5

Stage 2 is Raspberry Pi OS Lite; stages 3–5 add the desktop.

for s in 2 3 4 5; do touch stage$s/SKIP stage$s/SKIP_IMAGES; done

6. Create the minimal stage

mkdir -p stage-minimal/{00-apt-config/files,01-install-packages,02-sys-tweaks/files,03-firstboot/files,04-password-warning/files}

cat > stage-minimal/prerun.sh <<'EOF'
#!/bin/bash -e
if [ ! -d "${ROOTFS_DIR}" ]; then
    copy_previous
fi
EOF
chmod +x stage-minimal/prerun.sh

cat > stage-minimal/EXPORT_IMAGE <<'EOF'
IMG_SUFFIX="-minimal"
if [ "${USE_QEMU}" = "1" ]; then
    export IMG_SUFFIX="${IMG_SUFFIX}-qemu"
fi
EOF

6.1 apt and dpkg policy

APT treats Recommends as dependencies by default, which pulls in large optional stacks. path-exclude stops dpkg unpacking documentation and translations at all — no later removal can reclaim them.

cat > stage-minimal/00-apt-config/files/99-no-recommends <<'EOF'
APT::Install-Recommends "0";
APT::Install-Suggests "0";
EOF

cat > stage-minimal/00-apt-config/files/99-minimal-paths <<'EOF'
path-exclude /usr/share/doc/*
path-include /usr/share/doc/*/copyright
path-exclude /usr/share/man/*
path-exclude /usr/share/groff/*
path-exclude /usr/share/info/*
path-exclude /usr/share/lintian/*
path-exclude /usr/share/locale/*
path-include /usr/share/locale/en*
path-include /usr/share/locale/locale.alias
EOF

cat > stage-minimal/00-apt-config/00-run.sh <<'EOF'
#!/bin/bash -e
install -v -m 644 files/99-no-recommends "${ROOTFS_DIR}/etc/apt/apt.conf.d/99-no-recommends"
install -v -m 644 files/99-minimal-paths "${ROOTFS_DIR}/etc/dpkg/dpkg.cfg.d/99-minimal-paths"
on_chroot << 'CHROOT'
rm -rf /usr/share/man/?? /usr/share/man/??_* /usr/share/doc/*
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d ! -name 'en*' -exec rm -rf {} + 2>/dev/null || true
CHROOT
EOF
chmod +x stage-minimal/00-apt-config/00-run.sh

6.2 Package list

cat > stage-minimal/01-install-packages/00-packages <<'EOF'
dosfstools
ca-certificates
fake-hwclock
firmware-brcm80211
wpasupplicant
network-manager
openssh-server
sudo
cron
rpi-swap
raspberrypi-sys-mods
raspi-utils
rpi-eeprom
vim
less
procps
iputils-ping
rsync
EOF

fake-hwclock is required. The Pi has no RTC, so without it every boot starts at 1970 until NTP completes, and anything date-driven in that window — TLS certificate validation, shadow password aging — misbehaves.

6.3 System tweaks

printf '[Journal]\nSystemMaxUse=64M\nRuntimeMaxUse=16M\n' \
    > stage-minimal/02-sys-tweaks/files/00-size-limit.conf

cat > stage-minimal/02-sys-tweaks/00-run.sh <<'EOF'
#!/bin/bash -e

# Seed the clock so the first boot is not at epoch.
date -u '+%Y-%m-%d %H:%M:%S' > "${ROOTFS_DIR}/etc/fake-hwclock.data"

install -v -d "${ROOTFS_DIR}/etc/systemd/journald.conf.d"
install -v -m 644 files/00-size-limit.conf \
    "${ROOTFS_DIR}/etc/systemd/journald.conf.d/00-size-limit.conf"

on_chroot << CHROOT
usermod -aG sudo,adm,dialout,video,plugdev "${FIRST_USER_NAME}"
passwd -l root
systemctl enable ssh
systemctl disable NetworkManager-wait-online.service || true
systemctl set-default multi-user.target
apt-get purge -y nano
CHROOT
EOF
chmod +x stage-minimal/02-sys-tweaks/00-run.sh

nano arrives via debootstrap as Priority: important. Purging it promotes vim through update-alternatives, so /usr/bin/editor, visudo and crontab -e all use vim with no further configuration.

6.4 First-boot configuration

The config file that ships on the boot partition:

cat > stage-minimal/03-firstboot/files/server.conf <<'EOF'
# Edit this file, then boot. It lives on the FAT32 boot partition and is
# readable and writable on macOS, Windows and Linux with any text editor.
#
# Applied automatically whenever this file changes. Passwords below are blanked
# after they are applied.

# Changing USERNAME renames the account rather than adding a second one. Only
# possible while that user has no live session, so set it before the first boot.
USERNAME="admin"
PASSWORD=""

# Leave WIFI_SSID empty to skip wifi. The address comes from DHCP.
# If you later change the network, re-enter WIFI_PASSWORD as well.
WIFI_SSID=""
WIFI_PASSWORD=""

# Required for 5 GHz: without a regulatory domain those channels stay disabled.
WIFI_COUNTRY="IN"

# Set to "yes" to re-apply when nothing above has changed. Reset after applying.
FORCE_APPLY="no"
EOF

The script that consumes it:

cat > stage-minimal/03-firstboot/files/rpi4-firstboot <<'EOF'
#!/bin/bash
CONF=/boot/firmware/server.conf
STATE=/var/lib/rpi4-firstboot.checksum
PWDATE=/var/lib/rpi4-shipped-password.date

log() { echo "firstboot: $*"; }
[ -f "$CONF" ] || { log "no $CONF"; exit 0; }

. "$CONF" || { log "ERROR: $CONF is not valid shell"; exit 0; }

now=$(sha256sum "$CONF" 2>/dev/null | cut -d' ' -f1)
if [ -f "$STATE" ] && [ "$(cat "$STATE" 2>/dev/null)" = "$now" ] \
   && [ "${FORCE_APPLY:-no}" != "yes" ]; then
    log "config unchanged and FORCE_APPLY not set"
    exit 0
fi

USERNAME="${USERNAME:-admin}"

CURRENT=$(getent passwd 1000 | cut -d: -f1)
if [ -n "$CURRENT" ] && [ "$CURRENT" != "$USERNAME" ]; then
    if id -u "$USERNAME" >/dev/null 2>&1; then
        log "user $USERNAME already exists, using it"
    elif who | grep -q "^${CURRENT}\b"; then
        log "ERROR: $CURRENT is logged in, cannot rename to $USERNAME"
    else
        log "renaming $CURRENT -> $USERNAME"
        usermod -l "$USERNAME" -d "/home/$USERNAME" -m "$CURRENT" \
            && groupmod -n "$USERNAME" "$CURRENT" 2>/dev/null \
            && log "renamed" || log "ERROR: rename failed, keeping $CURRENT"
    fi
fi

if [ -n "${PASSWORD:-}" ]; then
    if id -u "$USERNAME" >/dev/null 2>&1; then
        log "setting password for $USERNAME"
        echo "${USERNAME}:${PASSWORD}" | chpasswd
        chage -l "$USERNAME" 2>/dev/null \
            | sed -n 's/^Last password change[^:]*:[[:space:]]*//p' > "$PWDATE"
        chmod 644 "$PWDATE"
    else
        log "ERROR: user $USERNAME does not exist"
    fi
fi

if [ -n "${WIFI_SSID:-}" ]; then
    log "configuring wifi: $WIFI_SSID"
    if [ -n "${WIFI_COUNTRY:-}" ]; then
        raspi-config nonint do_wifi_country "$WIFI_COUNTRY" 2>/dev/null \
            || log "could not set wifi country"
    fi

    if [ -z "${WIFI_PASSWORD:-}" ] && nmcli -g NAME con show | grep -qx "$WIFI_SSID"; then
        log "WIFI_PASSWORD blank and profile exists; keeping it"
    else
        nmcli con delete "$WIFI_SSID" 2>/dev/null
        if [ -n "${WIFI_PASSWORD:-}" ]; then
            nmcli con add type wifi con-name "$WIFI_SSID" ifname wlan0 \
                ssid "$WIFI_SSID" -- \
                wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$WIFI_PASSWORD" >/dev/null \
                && log "wifi profile created" || log "ERROR: profile not created"
        else
            nmcli con add type wifi con-name "$WIFI_SSID" ifname wlan0 \
                ssid "$WIFI_SSID" >/dev/null
        fi
    fi

    nmcli con up "$WIFI_SSID" 2>/dev/null \
        && log "wifi connected" || log "wifi will retry"
fi

log "scrubbing secrets from $CONF"
sed -i -e 's/^PASSWORD=.*/PASSWORD=""/' \
       -e 's/^WIFI_PASSWORD=.*/WIFI_PASSWORD=""/' \
       -e 's/^FORCE_APPLY=.*/FORCE_APPLY="no"/' "$CONF"
sync

install -d "$(dirname "$STATE")"
sha256sum "$CONF" 2>/dev/null | cut -d' ' -f1 > "$STATE"
log "complete"
exit 0
EOF

Its unit, ordered after NetworkManager so nmcli is usable:

cat > stage-minimal/03-firstboot/files/rpi4-firstboot.service <<'EOF'
[Unit]
Description=Apply /boot/firmware/server.conf on boot
After=NetworkManager.service network.target boot-firmware.mount
Wants=NetworkManager.service
RequiresMountsFor=/boot/firmware
ConditionPathExists=/boot/firmware/server.conf

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/rpi4-firstboot
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF

cat > stage-minimal/03-firstboot/00-run.sh <<'EOF'
#!/bin/bash -e
install -v -m 755 files/rpi4-firstboot "${ROOTFS_DIR}/usr/local/sbin/rpi4-firstboot"
install -v -m 644 files/rpi4-firstboot.service \
    "${ROOTFS_DIR}/etc/systemd/system/rpi4-firstboot.service"
install -v -m 644 files/server.conf "${ROOTFS_DIR}/boot/firmware/server.conf"
on_chroot << 'CHROOT'
systemctl enable rpi4-firstboot.service
CHROOT
EOF
chmod +x stage-minimal/03-firstboot/00-run.sh

6.5 Login notice while the shipped password is unchanged

cat > stage-minimal/04-password-warning/files/rpi4-password-warning.sh <<'EOF'
__rpi4_pw_warn() {
    local stamp=/var/lib/rpi4-shipped-password.date
    [ -r "$stamp" ] || return 0
    case "$-" in *i*) ;; *) return 0 ;; esac

    local shipped current
    shipped=$(cat "$stamp" 2>/dev/null) || return 0
    [ -n "$shipped" ] || return 0

    current=$(chage -l "$(id -un)" 2>/dev/null \
        | sed -n 's/^Last password change[^:]*:[[:space:]]*//p')
    [ -n "$current" ] || return 0
    [ "$current" = "$shipped" ] || return 0

    printf '\n\033[1;33m%s\033[0m\n' 'NOTICE: this account still uses the password that came on the boot card.'
    printf '  Change it when convenient:  \033[1mpasswd\033[0m\n\n'
}
__rpi4_pw_warn
unset -f __rpi4_pw_warn
EOF

cat > stage-minimal/04-password-warning/00-run.sh <<'EOF'
#!/bin/bash -e
install -v -m 644 files/rpi4-password-warning.sh \
    "${ROOTFS_DIR}/etc/profile.d/99-rpi4-password-warning.sh"
EOF
chmod +x stage-minimal/04-password-warning/00-run.sh

The notice clears itself: the last password-change date is compared against the date recorded when the shipped password was applied.

7. Write the build config

cat > config <<'EOF'
IMG_NAME='rpi4-minimal'
RELEASE='trixie'
STAGE_LIST='stage0 stage1 stage-minimal'

TARGET_HOSTNAME='rpi4-minimal'
FIRST_USER_NAME='admin'
FIRST_USER_PASS="${MINIMAL_FIRST_PASS:?set MINIMAL_FIRST_PASS in the environment}"
DISABLE_FIRST_BOOT_USER_RENAME=1
ENABLE_SSH=1

LOCALE_DEFAULT='en_GB.UTF-8'
TIMEZONE_DEFAULT='Asia/Kolkata'
KEYBOARD_KEYMAP='us'
KEYBOARD_LAYOUT='English (US)'
WPA_COUNTRY='IN'

DEPLOY_COMPRESSION='none'
EOF

8. Build

config is sourced both on the host and inside the container, so the password variable must be passed to both.

cd ~/build/pi-gen
sudo MINIMAL_FIRST_PASS='raspberry' \
     PIGEN_DOCKER_OPTS='-e MINIMAL_FIRST_PASS=raspberry' \
     ./build-docker.sh

Expect roughly 30 minutes; every dpkg call runs under emulation. The result:

ls -lh deploy/*.img

Rebuilding

Remove the previous container first, or the build aborts: docker rm -v pigen_work

9. Burn

Identify the target device and confirm it is the right one before writing.

lsblk -o NAME,SIZE,TYPE,MOUNTPOINT
cat /sys/block/sdX/removable    # must print 1

Unmount anything mounted from it, then write:

sudo umount /dev/sdX* 2>/dev/null
sudo dd if=deploy/2026-*-rpi4-minimal-minimal.img of=/dev/sdX bs=4M conv=fsync status=progress
sync

On Linux, umount plus sync is safe removal; there is no separate eject step.

10. Configure

Insert the card or stick into any machine. The FAT32 partition mounts as bootfs. Edit server.conf on it:

USERNAME="admin"
PASSWORD="choose-a-password"
WIFI_SSID="your-ssid"
WIFI_PASSWORD="your-wifi-password"
WIFI_COUNTRY="IN"
FORCE_APPLY="no"

Unmount, move to the Pi 4, and power on.

11. Find and log in

The first boot generates SSH host keys and joins wifi. Allow 2–3 minutes. The address comes from DHCP:

arp -a | grep -i "dc:a6:32"      # Raspberry Pi Foundation MAC prefix
ssh admin@<address>

12. Post-boot steps

Run these once, on the booted box.

Grow the root partition and filesystem to fill the device. Substitute /dev/sda (USB) or /dev/mmcblk0 (SD card) for the disk:

findmnt -no SOURCE /                          # confirm the root partition
sudo sfdisk -N 2 --force /dev/sda <<< ", +"   # extend partition 2 to disk end
sudo partx -u /dev/sda                        # re-read the new size
sudo resize2fs /dev/sda2                      # grow the filesystem
df -h /

Set your own password:

passwd

Refresh the package index:

sudo apt update

13. Verify

df -h /                          # fills the device after step 12
free -h                          # ~156 MB used
systemd-analyze                  # ~9 s to multi-user.target
systemctl --failed               # empty
dpkg-query -Wf '${Package}\n' | wc -l   # ~305
journalctl -u rpi4-firstboot -b  # what the config applied

Booting from USB

The Pi 4 boots USB mass storage when the EEPROM boot order allows it:

sudo rpi-eeprom-config | grep BOOT_ORDER

0xf41 reads right-to-left: 1 = SD card, 4 = USB, f = retry loop. With an SD card inserted the Pi boots that first, so remove it to boot from USB.

Changing the configuration later

Edit server.conf on the boot partition and reboot; the change is detected by checksum and applied. To re-apply without changing anything — to retry a failed wifi join, for example — set FORCE_APPLY="yes".

Static addressing, SSH keys and other host configuration are done over SSH once the box is reachable.