SD Card Distribution

Published

October 26, 2026

Modified

October 26, 2026


This guide explains how to prepare and ship a Raspberry Pi SD card image with the Digitization Toolkit pre-installed, so the recipient can plug it in and access the application from any device on the same network — without any manual IP configuration.


How It Works

The Raspberry Pi broadcasts its hostname over the local network using mDNS (via the Avahi daemon, pre-installed on Raspberry Pi OS). This means any device on the same network can reach the Pi at http://<hostname>.local without knowing its IP address. Every unit is called captua, so it answers at http://captua.local.

The browser calls the API at /api on the page’s own origin, and Nginx on port 80 passes it to the backend. The name therefore matters only for finding the unit: opening it by its IP address works the same, and so does any other name that reaches it.


Preparing the Master Image

1. Name the unit

setup.sh names the unit captua (step 6f, scripts/provision-hostname.sh): it sets the hostname and the 127.0.1.1 line of /etc/hosts, and sets HOST_IP=captua.local and adds http://captua.local to CORS_ORIGINS in .env. Nothing needs editing by hand.

Two units on one network cannot share a name. For the second unit at such a site, give it its own name when running setup:

sudo DTK_HOSTNAME=captua-2 ./scripts/setup.sh

Verify:

hostname
# captua
grep -E '^(HOST_IP|CORS_ORIGINS)=' /home/pi/dtk/.env

2. Confirm Avahi is running

Avahi (mDNS) is pre-installed on Raspberry Pi OS. Confirm it is active:

systemctl is-enabled avahi-daemon
systemctl is-active avahi-daemon

Both should return enabled / active. If not:

sudo systemctl enable --now avahi-daemon

3. Verify

Open http://captua.local from another device on the same network, and the unit’s IP address too: both load the app.

HOST_IP and CORS_ORIGINS no longer route anything on the appliance. The frontend reads PUBLIC_API_BASE=/api at runtime (docker-compose.pi.yml), and HOST_IP sets only SvelteKit’s ORIGIN. Both are kept correct for the code a rollback can restore, whose frontend calls http://${HOST_IP}/api.

4. Image the SD card

Once the system is working correctly, shut down the Pi cleanly and image the SD card from another machine:

macOS / Linux:

# Identify the SD card device (e.g. /dev/disk4 or /dev/sdb)
diskutil list           # macOS
lsblk                   # Linux

# Create compressed image
sudo dd if=/dev/sdX bs=4M status=progress | gzip > dtk-$(date +%Y%m%d).img.gz

Windows: Use Raspberry Pi Imager or Win32DiskImager to read the card to a .img file.

TipShrink before imaging

Tools like PiShrink can reduce the image size significantly before compression by trimming unused filesystem space.

NoteSecrets are per-unit — imaging is safe at any point

SECRET_KEY and DATABASE_PASSWORD are regenerated automatically on each physical unit by dtk-secrets.service (installed by install-service.sh). It records the Pi’s hardware serial in /var/lib/dtk/secrets-provenance; when a flashed clone boots on a different Pi, the serial mismatch triggers fresh secrets before the backend starts — including rotating the Postgres password inside the cloned data directory. You do not need to scrub secrets from the master image, and test-booting the master before imaging is fine.

WarningNever add a credential to .env by hand

The per-unit generation above covers SECRET_KEY and DATABASE_PASSWORD. It does not cover anything you add yourself while preparing a card — and whatever sits in .env at imaging time is copied into every clone of that image. The Rionegro card shipped with a live GitHub personal access token in plaintext in ~/dtk/.env. It was unused and has been revoked, but nothing in the software put it there.

Provisioning never needs a token: the repositories are public and setup.sh clones them anonymously. If a private fetch is ever required, read the token from a prompt so it never appears on the command line, then clear it:

read -rs -p "Token: " GH_TOKEN && export GH_TOKEN
gh repo clone UCSB-AMPLab/<repo>
unset GH_TOKEN

Do not pass it inline as GH_TOKEN=… gh …: that writes the token verbatim into ~/.bash_history, which sits on the card and is copied into the image along with everything else. For the same reason, never embed a token in a remote URL — it persists in .git/config and travels with the image.

Before imaging a master card, diff .env against .env.example: it should carry no keys the template does not have.


Flashing and Delivering the Image

Flash the image onto a new SD card:

# Linux / macOS
gzip -dc dtk-YYYYMMDD.img.gz | sudo dd of=/dev/sdX bs=4M status=progress

Or use Raspberry Pi Imager → Use custom image → select the .img.gz file.


What the Recipient Needs to Do

Nothing, in most cases. They:

  1. Insert the SD card and power on the Pi.
  2. Connect the Pi to their local network (Ethernet or Wi-Fi configured before imaging).
  3. Open http://captua.local from any browser on the same network.
NoteWi-Fi pre-configuration

If the destination uses Wi-Fi (not Ethernet), configure the network credentials on the SD card before shipping.

Raspberry Pi OS Bookworm replaced dhcpcd + wpa_supplicant with NetworkManager, so dropping a wpa_supplicant.conf on the /boot partition no longer works — Bookworm ignores that file.

  • Preferred: use Raspberry Pi Imager’s Advanced options (gear icon, or Ctrl+Shift+X) when flashing to preconfigure the Wi-Fi SSID and password before the card is ever booted.
  • Post-flash: configure Wi-Fi via NetworkManager, e.g. sudo nmcli device wifi connect "<SSID>" password "<password>", or by dropping a NetworkManager keyfile connection profile under /etc/NetworkManager/system-connections/.
  • Enable SSH in the same Advanced options screen (public-key auth if possible). Headless provisioning is impossible without it: on a fresh Raspberry Pi OS Lite install SSH is off by default, and there is no way in afterwards short of attaching a keyboard and display.

Troubleshooting

captua.local doesn’t resolve

  • Windows requires Bonjour (installed automatically with iTunes or iCloud). Windows 10 (1803+) supports mDNS natively.
  • Try pinging: ping captua.local
  • As a fallback, find the IP via the router’s DHCP client list and open it directly: the app works by IP.

A unit in service before 0.1.0 answers as digitool.local

Units set up before 0.1.0 were called digitool. update.sh renames them to captua and keeps digitool.local answering through 0.1.x (see Updating a unit).

CORS errors in the browser

The browser calls the API on the page’s own origin, so the production stack has no cross-origin calls and this error should not occur there. On the development stack (Vite on :5173 calling the backend on :8000), the page’s origin must be in CORS_ORIGINS in .env; update that list and restart the backend.

Two Pis on the same network

mDNS hostnames must be unique per network. Name the second unit at setup with DTK_HOSTNAME=captua-2 (step 1). On a unit already set up, run the same script by hand:

sudo /home/pi/dtk/scripts/provision-hostname.sh --name captua-2
sudo systemctl restart dtk