Cross-compiling to aarch64 (Raspberry Pi 4/5)

The product’s deployment target is a Raspberry Pi 4 or 5 running Raspberry Pi OS Trixie (Debian 13, aarch64/ARMv8-A). This documents how to cross-compile the single self-contained server binary for that target and verify it, and how the binary is configured at runtime on the Pi.

This is the cross sibling of build.md — read that first for why the release build is two steps. The same rust-embed compile-time embed applies here; the only difference is that the second step cross-compiles.

TL;DR

./scripts/build-aarch64.sh
#   → target/aarch64-unknown-linux-gnu/release/server   (one aarch64 ELF, assets embedded)

Prerequisites:

  • cargo install cross — a host dev tool (not a workspace dependency).

  • A running container engine — Docker or Podman. cross runs the aarch64 toolchain inside a container; scripts/build-aarch64.sh preflight-checks that both are present and the daemon is reachable.

  • cargo install cargo-leptos — already required for the native build.

One binary serves both Pi 4 (Cortex-A72) and Pi 5 (Cortex-A76): both are aarch64/ARMv8-A. There is no per-Pi build.

Why it is the same two-step build as the native release

rust-embed folds target/site into the binary at compile time, but cargo-leptos 0.3.6 compiles the server crate before it writes the WASM/JS. So a single cargo leptos build embeds an incomplete bundle (WASM/JS missing → /pkg/hamradio.wasm 404s). The fix is to build site/ first, then re-compile server to re-embed the complete bundle — and the cross build is that second step:

export LEPTOS_OUTPUT_NAME=hamradio
export LEPTOS_SITE_PKG_DIR=pkg

# 1. native: produce a COMPLETE target/site. The site is architecture-independent
#    (there is no aarch64 WASM), so building it natively is correct and fast.
cargo leptos build --release

# 2. cross: re-embed the complete site/ INTO the aarch64 server binary.
cargo clean -p server --release --target aarch64-unknown-linux-gnu
cross build -p server --release --target aarch64-unknown-linux-gnu
#   → target/aarch64-unknown-linux-gnu/release/server

scripts/build-aarch64.sh wraps exactly these steps (with preflight checks).

Do not set bin-cargo-command = "cross" under . That makes cargo leptos build cross-compile the server inside the single leptos pass — i.e. before the WASM exists — re-triggering the incomplete-embed bug, now hidden inside a container. Keep step 1 native; cross only in step 2.

cross bind-mounts the repo into its container, so rust-embed’s $CARGO_MANIFEST_DIR/../../target/site (crates/server/src/assets.rs) resolves to the workspace target/site produced by step 1. Cross.toml passes LEPTOS_OUTPUT_NAME / LEPTOS_SITE_PKG_DIR into the container so the option_env!-baked hydration paths come out as /pkg/hamradio.{js,wasm}.

glibc compatibility (no custom image needed)

cross's stock aarch64-unknown-linux-gnu image links against an older glibc for portability. glibc is forward-compatible: a binary built against old glibc runs on the newer glibc (≈2.41) that Raspberry Pi OS Trixie ships. So the default cross image is correct — no custom image, no glibc pinning. Our deps are pure-Rust (redb, rust-embed/mime_guess, minisign-verify), so there is no C toolchain and no -sys cross friction. If a cross build ever pulls an unexpected -sys crate, stop and investigate — a C dependency has crept in against the pure-Rust discipline.

Verifying the embed survived cross-compilation

The headline failure mode is a silently-incomplete embed — the cross binary launches but 404s /pkg/hamradio.wasm. Two complementary checks:

Static (no hardware, always runnable)

BIN=target/aarch64-unknown-linux-gnu/release/server
file "$BIN"                              # → ELF 64-bit LSB ... ARM aarch64
strings -n 8 "$BIN" | grep -c hamradio.wasm   # → ≥ 1 (the embedded asset path)

A file that reports ARM aarch64 plus the embedded hamradio.wasm reference proves the bytes are in the binary. The unit test crates/server/src/assets.rs::tests::embed_is_non_empty (run by cargo test --workspace on the host) is the compile-time guard at the source level.

Dynamic — QEMU user emulation (no Pi, strongly preferred)

You can boot and serve the aarch64 binary on an x86_64 host via QEMU, with no Raspberry Pi. Register the arm64 emulation handler once (idempotent), then run the binary inside an arm64 container and point the self-contained verifier at it:

# Register qemu-aarch64 binfmt once (persists until reboot / re-register):
docker run --privileged --rm tonistiigi/binfmt --install arm64

# Serve the cross binary inside a NAMED arm64 container on port 3737 (the name
# lets us stop it at the end; without it the container leaks and the next run
# fails with "port 3737 already allocated"):
docker run --rm --name hamradio-qemu --platform linux/arm64 \
  -v "$PWD":/w -w /w \
  -e LEPTOS_SITE_ADDR=0.0.0.0:3737 -p 3737:3737 \
  arm64v8/debian:trixie-slim \
  ./target/aarch64-unknown-linux-gnu/release/server &

# Wait for the server to bind before probing — QEMU boot is slow, so curling
# immediately races the listener and spuriously fails the first check:
for _ in $(seq 1 30); do
  curl -fsS -o /dev/null http://127.0.0.1:3737/ 2>/dev/null && break
  sleep 1
done

# Re-use the empty-dir verifier's curl checks against the running aarch64 server:
curl -fsS -o /dev/null -w '%{http_code} %{content_type}\n' http://127.0.0.1:3737/pkg/hamradio.wasm
curl -fsS -o /dev/null -w '%{http_code} %{content_type}\n' http://127.0.0.1:3737/pkg/hamradio.js
curl -fsS -o /dev/null -w '%{http_code} %{content_type}\n' http://127.0.0.1:3737/pkg/hamradio.css
curl -fsS -o /dev/null -w '%{http_code} %{content_type}\n' http://127.0.0.1:3737/favicon.ico
curl -fsS http://127.0.0.1:3737/ | grep -o '<script type="module"'

# Stop the emulated server (frees port 3737 for re-runs):
docker stop hamradio-qemu

This is a genuine "launches + serves embedded assets on aarch64" proof. The only thing it cannot prove is real-silicon behaviour (thermal, real NIC, real performance) — that is the on-Pi step below.

On-Pi acceptance — Pi 4 and Pi 5 (operator step)

The cross build and the emulation proof run on the dev host; the final acceptance runs on real hardware (Bench Spike 0 / Story 0.8 uses the same two Pis). On a Pi 4 and a Pi 5, both running Raspberry Pi OS Trixie:

# 1. Copy the single binary to the Pi (nothing else — assets are embedded):
scp target/aarch64-unknown-linux-gnu/release/server pi@<pi-host>:~/server

# 2. On the Pi: make it executable and run it, bound to the LAN:
chmod +x ~/server
LEPTOS_SITE_ADDR=0.0.0.0:3000 ~/server
#   → "listening on http://0.0.0.0:3000"

# 3. From another host on the LAN, confirm every embedded asset + the SSR shell:
PI=<pi-ip>:3000
for p in /pkg/hamradio.wasm /pkg/hamradio.js /pkg/hamradio.css /favicon.ico; do
  curl -fsS -o /dev/null -w "%{http_code} %{content_type}  $p\n" "http://$PI$p"
done
curl -fsS "http://$PI/" | grep -q '<script type="module"' && echo "OK: / SSR + hydration"

Expected: each asset returns 200 with the correct Content-Type (application/wasm, text/javascript, text/css, image/x-icon), and / returns SSR HTML containing the hydration <script type="module">. Run this on both the Pi 4 and the Pi 5.

Runtime configuration on the Pi (where the listen address comes from)

crates/server/src/main.rs calls get_configuration(None), which reads the LEPTOS_* environment, falling back to leptos defaults (127.0.0.1:3000, localhost-only). On a headless Pi you almost always want it reachable on the LAN, so set:

LEPTOS_SITE_ADDR=0.0.0.0:3000 ./server

This is the primary, recommended approach — it keeps the single-artifact story intact: one file, an optional env var, no sibling config.

An alternative (from AR-3) is to place a Cargo.toml beside the binary and call get_configuration(Some("Cargo.toml")); we prefer the env var and do not wire the sibling-config path here.

Crucially: this env is runtime config only — listen address/port. The static assets (WASM/JS/CSS/favicon) remain embedded in the binary; there is no external site/ directory on the Pi. Copy one file, set one env var.

Auto-start as a service (systemd unit, Environment= / EnvironmentFile=) is Story 4.1 — out of scope here. This document covers the manual run only.