How to Build an Embedded Linux Image from Containers
What You’ll Build
A single flashable .wic image where every component — kernel, BSP, system services, applications — is a container, assembled at Yocto build time and ready to write to SD/eMMC.
After flashing, the device boots into Pantavisor with the entire composition in /trails/0/. Subsequent updates ship as OTA state revisions via PVR — see Update Embedded Linux Firmware OTA.
The Two-Phase Model
| Phase | Tool | Output |
|---|---|---|
| Build — produce a flashable initial image | Yocto + meta-pantavisor |
.wic |
| Maintain — update the running device | PVR + Pantahub | OTA state revisions |
This guide covers the build phase. PVR cannot produce a flashable image; Yocto cannot OTA a running device. Each tool owns its phase.
Prerequisites
- A Yocto host (Linux with build essentials) — or use Docker/Podman + KAS for a clean environment.
- Hardware target supported by
meta-pantavisor(Raspberry Pi, etc.) or your own MACHINE definition.
Step 1 — Get meta-pantavisor
git clone https://github.com/pantavisor/meta-pantavisor.git
cd meta-pantavisorThe layer ships KAS configs for common boards plus the pvroot-image,
pvrexport, and container-pvrexport BitBake classes that do the heavy
lifting.
Step 2 — Build with KAS (Recommended)
KAS reads a YAML config and orchestrates the BitBake build. For a Raspberry Pi 4 starter image:
kas build kas/scarthgap.yaml:kas/machines/raspberrypi-armv8.yaml:kas/bsp-base.yamlKAS pulls layer dependencies (poky, meta-openembedded, etc.), sets the
MACHINE, and runs bitbake pantavisor-starter. The output lands in:
build/tmp-scarthgap/deploy/images/raspberrypi-armv8/pantavisor-starter-raspberrypi-armv8.wicThat .wic is the full bootable image — BSP + Pantavisor runtime +
every container in PVROOT_CONTAINERS_CORE.
Step 3 — (Optional) Customize the Composition
The image recipe declares which containers get baked in:
SUMMARY = "Starter Image for Pantavisor"
LICENSE = "MIT"
inherit image pvroot-image
# Core containers (always present in initial state)
PVROOT_CONTAINERS_CORE ?= "pv-pvr-sdk pv-alpine-connman pvwificonnect"
# Optional containers bundled as factory packages for first-boot install
PVROOT_CONTAINERS ?= "myapp my-monitoring"
# Underlying BSP image
PVROOT_IMAGE_BSP ?= "core-image-minimal"Each entry in PVROOT_CONTAINERS_CORE is a Yocto recipe that produces a
.pvrexport.tgz. Three recipe sources are supported (mix freely):
From the GitLab Package Registry
Fastest. Pulls a pre-built .pvrexport.tgz:
SUMMARY = "Alpine Linux + ConnMan platform container"
LICENSE = "CLOSED"
inherit pvrexport
BB_STRICT_CHECKSUM = "0"
PVCONT_NAME = "os"
SRC_URI += "\
https://gitlab.com/api/v4/projects/pantacor%2Fpv-platforms%2Falpine-connman/packages/generic/alpine-connman/${PV}/alpine-connman.${PV}.${DOCKER_ARCH}.tgz;name=os;subdir=${BPN}-${PV}/pvrrepo/.pvr \
file://mdev.json \
"Built from Scratch with Yocto
Full control over the container’s contents:
SUMMARY = "Pantavisor WiFi Connect container"
LICENSE = "MIT"
inherit core-image container-pvrexport
IMAGE_BASENAME = "pvwificonnect"
IMAGE_FSTYPES = "pvrexportit"
IMAGE_INSTALL += "busybox pvwificonnect-app"
SRC_URI += "file://args.json file://config.json"
PVR_APP_ADD_GROUP = "platform"Wrapped from a Docker / OCI Image
Reuse upstream or third-party containers:
SUMMARY = "Alpine D-Bus container from Docker"
LICENSE = "CLOSED"
inherit pvrexport
BB_STRICT_CHECKSUM = "0"
PVR_DOCKER_REF = "asac/alpine-dbus:latest"
PVR_APP_ADD_EXTRA_ARGS += " \
--volume /var/pvr-volume-boot:boot \
--volume /var/pvr-volume-revision:revision \
--volume /var/pvr-volume-permanent:permanent \
"Step 4 — What pvroot-image Does at Build Time
When BitBake assembles the image, pvroot-image:
- Builds each container recipe (if not cached).
- Extracts the resulting
.pvrexport.tgzartifacts. - Deploys them into
/trails/0/of the rootfs viapvr deploy. - Mixes in the BSP container (
pantavisor-bsp) automatically. - Generates
device.jsonwith groups, volumes, and disks. - Wraps everything as a flashable
.wic(or.pvrexport.tgzfor OTA-only deployment).
Result: a .wic that boots straight into a fully-composed Pantavisor system on first power-on.
Step 5 — Flash
Use pvflasher (handles .wic, .bmap, and compressed images, verifies
checksums, supports Linux/macOS/Windows):
# Install
curl -fsSL https://raw.githubusercontent.com/pantavisor/pvflasher/main/scripts/install.sh | bash
# List candidate disks
pvflasher list
# Flash
sudo pvflasher copy \
build/tmp-scarthgap/deploy/images/raspberrypi-armv8/pantavisor-starter-raspberrypi-armv8.wic \
/dev/sdXReplace /dev/sdX with the actual target — flashing the wrong disk destroys data irreversibly.
What Happens on First Boot
- Bootloader hands off to the kernel from the BSP container.
- Pantavisor takes over as PID 1, mounts
/trails/0/as the active state. - Each container in the composition starts according to its
groupandstatus_goal. - The device registers with Pantahub (if configured) and becomes manageable via PVR.
Common Pitfalls
- Forgetting layer dependencies —
meta-pantavisorneeds poky and meta-openembedded layers inbblayers.conf. KAS handles this automatically; manual setups don’t. - Wrong MACHINE — match the KAS machine YAML to your hardware exactly. RPi 4 is
raspberrypi-armv8, notraspberrypi4. - Confusing
.pvrexport.tgzwith.wic—.pvrexport.tgzis a deploy bundle for OTA;.wicis the flashable initial image. Don’t try to flash a.pvrexport.tgzto bare media. - Treating PVR as a build tool —
pvrdoes not build the image. It manages the state of an already-running Pantavisor device.
Next Steps
- Composable Firmware — Full conceptual + practical walkthrough
- Yocto Integration Guide — Deep dive on meta-pantavisor
- Containerize Embedded Linux Applications — Author your own container recipes
- Update Embedded Linux Firmware OTA — Ship updates after first boot