The limited lifespan of SD-Cards when used as primary storage medium in a Raspberry Pi is a well knwon-problem. The SD-Card in one of our Raspberry Pi died a few days ago. It wasn’t the first time and it won’t be the last. Setting up a new system, configuring everything and installing the necessary software is a repetitive and time-consuming task. Let’s build a custom image of Raspberry Pi OS to alleviate this problem in the future.

If you just want to see the end result, take a look at our Gitlabexterner Link.

Why not [Insert Infrastructure as Code Tool here]?

Infrastructure as code tools, such as Ansible, have one big advantage over images. You can easily make changes to infrastructure that is already deployed. It is no problem to install an additional package or change a configuration for a running system with these tools. With images, this is a lot more work. In our case, image deployment is still manual 1 and the system is completely wiped with each new image.

But these tools are not suitable for our use case because they require internet access. Installing or updating packages (often with several different package managers) to set up a system requires internet access. Our device only has access to the internal network. So this is a knock-out criterion for us. Images, on the other hand, can contain everything that is needed to deploy the system. Images can even be deployed on devices that have no network access at all.

Toolchain

To build the image, we use pi-genexterner Link. pi-gen is the tool used to build the official Raspberry Pi OS images. It is basically a collection of scripts that build a Raspberry Pi OS image file.

The build is supported on Debian-based systems. The build process can also be run in a Docker container using build-docker.sh. If your builds fails with an exec format error, you may need to run docker run --rm --privileged multiarch/qemu-user-static --reset -p yes.

Start the build using build.sh or docker-build.sh. Inspect a failed docker build with sudo docker run -it --privileged --volumes-from=pigen_work pi-gen /bin/bash. You can reuse the state of the last build with CONTINUE=1. This is useful after failed builds to save time, but should not be used for the final build. PRESERVE_CONTAINER=1 prevents the container from being removed after a succesful build.

Configuring the Image

I am doing this explanation using the example of an image that I built for a Raspberry Pi in a vending machine. The custom stage installs the vending machine software and configures a static IP. For ease of deployment, the initial user is kept with a default password. This eliminates the need for user interaction at first boot. To reduce risk, the device is deployed on an internal network and can only be accessed over ssh using public key authentication.

General Configuration

Some basic configuration is done in the config file. This file contains a list of environment variables that define some basic aspects of the build. The full list of options can be found in the READMEexterner Link. Here is a short list of the most important options:

NameDefaultDescription
IMG_NAMEunset
LOCAL_DEFAULT“en_GB.UTF-8”
KEYBOARD_KEYMAP“gb”Use debconf-show keyboard-configuration and look at keyboard-configuration/xkb-keymap to get the current value on your system.
KEYBOARD_LAYOUT“English (UK)”Use debconf-show keyboard-configuration and look at keyboard-configuration/variant to get the current value on your system.
TARGET_HOSTNAME“raspberrypi”
TIMEZONE_DEFAULT“Europe/London”
ENABLE_SSH0
PUBKEY_SSH_FIRST_USERunset
PUBKEY_ONLY_SSH0Set to 1 to only allow SSH using public key authentication.
STAGE_LIST“stage*”Set to stage0 stage1 stage2 custom to build an image with a custom stage that is based of the lite image.
FIRST_USER_NAME“pi”User only exists during build process. Final user is created on first boot.
FIRST_USER_PASSunset
DISABLE_FIRST_BOOT_USER_RENAME0Set this to 1 to to avoid creating a new user on first boot. It is generally a bad idea to ship images with default passwords.

Build structure

If you’re building your image based on the lite version your build might look like this:

graph TD subgraph stage0 [stage0] direction LR prerun.sh --> 00-configure-apt subgraph 00-configure-apt 00-run.sh --> 01-packages end subgraph 01-locale 00-debconf --> 00-packages end subgraph 02-firmware 02-01-packages[01-packages] end 00-configure-apt --> 01-locale 01-locale --> 02-firmware end subgraph stage1 [stage1] a[...] end subgraph stage2 [stage2] b[...] end subgraph custom [custom] direction LR c-prerun.sh[prerun.sh] --> 00-pimate subgraph 00-pimate direction TB c00-packages[00-packages] --> c01-run.sh[01-run.sh] c01-run.sh --> c02-run-chroot.sh[02-run-chroot.sh] c02-run-chroot.sh --> c03-patches subgraph c03-patches [03-patches] crc_local.diff[rc_local.diff] end end 00-pimate --> 01-network subgraph 01-network c00-run.sh[00-run.sh] end 01-network --> EXPORT_IMAGE end stage0 --> stage1 stage1 --> stage2 stage2 --> custom

Let us take a close look at the build process using this example. There are three layers in the build process: stages (stage0, stage1, …), tasks (00-configure-apt, 01-locale, …) and actions (00-run.sh, 01-packages, …).

  • Folders matching stage* will be executed as stages in alphanumeric order. STAGE_LIST can be used to define a custom list of stages to be executed in the order of the list. Here they are stage0, stage1, stage2, and custom.
    • Run prerun.sh. This will usually only copy the result of the last stage to the current stage.
    • Loop through each subdirectory in alphanumeric order. 00-configure-apt, 01-locale, and 02-firmware for stage0
      • Run the actions in alphanumeric order. 00-run.sh and 01-packages for 00-configure-apt from stage0
    • Generate an image at the very end if the stage contains a file called EXPORT_IMAGE.

Available Actions

??-packages
A list of packages to install, separated by newlines or spaces.
??-run-chroot.sh
Script to run in the chroot of the image. Must be executable.
??-run.sh
Script to run. Must be executable.
??-patches
Directory with quilt patches to apply

A full list of the actions can be found in the READMEexterner Link.

Putting it all together

  1. Create a config file with the configuration you want.
  2. Create your custom stage with the individual tasks and actions.
  3. Build the image.
  4. Burn the image to an SD-Card, for example using the Raspberry Pi Imagerexterner Link.

The final result can be found on our Gitlabexterner Link.


  1. PXE could be an option to automate the deployment. But we still need to evaluate whether PXE is even possible. ↩︎