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:
Verify:
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.
5. Skip stages 2–5¶
Stage 2 is Raspberry Pi OS Lite; stages 3–5 add the desktop.
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:
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.
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:
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:
Refresh the package index:
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:
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.