Ubuntu Core for Embedded Devices
Status: This page is background reference on how Ubuntu Core is built and customized for embedded hardware. It describes Canonical’s system, not a
[yoe]feature —[yoe]does not build Ubuntu Core images and has no roadmap to. It exists so readers evaluating the two can understand Ubuntu Core’s customization model on its own terms. The closing section, How[yoe]could assemble Ubuntu Core images, is a forward-looking design exploration — it sketches a feasible implementation seam to make the boundary concrete, but no such code exists and the project has not committed to building it. For the shipped head-to-head comparison, see the Ubuntu Core section of Comparisons.
Ubuntu Core is Canonical’s snap-based, immutable, transactionally-updated variant of Ubuntu, aimed at IoT, edge, and appliance devices. Customizing it for a board is almost entirely about assembling and signing your own set of snaps — there is no “edit the rootfs” step the way there is with a classic distribution. Everything read-only on the device is a snap, and the image is generated from a signed model assertion. This page walks through the pieces people actually touch when bringing Ubuntu Core up on custom hardware.
The model assertion is the image
The model is a signed JSON document that is the definition of an image. It names the components that constitute a device:
architecture—amd64,arm64, orarmhfbase— the root snap (core20/core22/core24)kernel— the kernel snapgadget— the board-support snapsnaps[]— application and extra snaps, each pinned to a channel or revision
You sign the model with a brand key, then build the bootable image with
ubuntu-image:
$ ubuntu-image snap my-model.json
So “customizing Ubuntu Core” reduces to customizing the snaps the model points at — gadget, kernel, and your apps — then re-signing the model. The base snap is rarely touched.
The four customization surfaces
1. Gadget snap — the board vehicle
The
gadget snap
is where almost all board-level customization lives. You typically fork a
reference gadget — pi-gadget for
Raspberry Pi, pc-amd64-gadget for PC-class hardware — and edit its
gadget.yaml:
- Partition and volume layout, sizes, filesystem types
- Bootloader choice and assets (
grub,u-boot,lk,piboot) - Kernel command-line additions
defaults:— first-boot snap configuration keyed by snap-id (for example, disablingconsole-conf, or setting default system options)connections:— interface auto-connections wired in ahead of time
2. Kernel snap — kernel, DTBs, modules, initramfs
Board bring-up — a new SoC, a custom carrier board, extra peripherals — happens
in the kernel snap. You fork the reference kernel snap (for example
pi-kernel), adjust the kernel config
or defconfig, add patches and out-of-tree drivers, and ship your device-tree
blobs and overlays. It is built with snapcraft’s
kernel plugin.
3. Base snap — rarely customized
Most devices use the stock core22 / core24 base. A custom base is only
warranted when you need a non-standard libc or runtime layer, which is unusual.
4. Application snaps — your software
Apps ship as strictly confined snaps. To reach hardware you declare
plugs/slots against Ubuntu Core’s
interfaces (gpio,
serial-port, network, and so on), and use
hooks (install, configure,
prepare-device) for lifecycle and configuration. Apps are listed in the model
so they are seeded into the image.
First-boot and headless provisioning
Embedded devices are usually headless, so common customizations are:
- Bypass
console-conf— the interactive first-boot setup is disabled via the gadgetdefaults. - Auto-create a user — ship a system-user assertion so an account is created at first boot without interaction.
- Limited cloud-init — Ubuntu Core restricts the available datasources, but
a gadget can embed a
cloud.conffor grub-based images. - Offline seeding — pre-seed all snaps so the device boots fully provisioned with no network.
Development versus production
- Development: build and sign with your own key in dangerous mode, pull
snapd with
--snapd-from-edge, use unasserted ordevmodesnaps, and iterate withsnap try. No store is required. - Production: register a brand account, create a signing key
(
snapcraft create-key/register-key), and — for fleet delivery and controlled updates — set up a brand store / dedicated snap store. Updates are transactional with automatic rollback; you control cadence withrefresh.timer/refresh.holdand channel pinning in the model.
Constraints on cost-sensitive hardware
Two properties matter most when weighing Ubuntu Core for low-end embedded:
- The store gate. Serious fleet management — private snaps, controlled rollout, device assertions at scale — effectively wants a dedicated or brand store, which is a commercial relationship with Canonical.
- The size floor. A minimum Ubuntu Core install lands around 2.5 GiB before any application code — see below for the breakdown. That rules out the small-flash, 128–512 MiB class of device.
Why the base image is so large
No single component is large. Ubuntu Core multiplies copies of the boot-critical components to guarantee transactional rollback and factory recovery, and four factors compound:
-
Sealed snaps, no cross-snap sharing. The base ships as four squashfs snaps —
core24(~70 MB), the kernel snap (~100+ MB: kernel image, all modules, firmware, and the initramfs in one),snapd(~40 MB), and the gadget (a few MB). Each is self-contained, so a library shared between them is not deduplicated the way it would be in a shared FHS root. -
Revision retention, full copies not deltas. snapd keeps
refresh.retain + 1revisions of every snap (defaultretain = 2, so three copies) plus a temporary fourth during a refresh — roughly 4× per snap. Each revision is a complete squashfs; there is no on-disk delta. The ~100 MB kernel snap is therefore budgeted at ~400 MB. -
A recovery seed that duplicates the boot snaps. The
system-seedpartition holds a second complete copy of the kernel, base, snapd, and gadget snaps plus their assertions, so the device can reinstall or factory-reset — another full set of the core snaps. -
Several partitions, each with reserve. The layout is four partitions, each carrying filesystem overhead and headroom snapd reserves for the next refresh’s temporary copy. From Canonical’s partition-sizing guidance:
Partition Minimum Holds system-seed~457 MiB recovery bootloader, recovery snap copies, assertions system-boot~160 MiB kernel EFI image(s), boot state system-save~32 MiB device identity and recovery data system-datavariable run-mode snaps, retained revisions, writable data
The structural reason is the architecture itself: everything is a read-only snap that is never modified in place, so atomic update, rollback, and factory recovery all require keeping whole alternate copies — current, next, a few old, and a recovery duplicate — rather than mutating files or storing deltas. The size buys those guarantees.
These two points are exactly where [yoe] makes different choices — .apk /
.deb packages into a shared FHS root, self-hosted signed repositories, and
atomic image updates rather than snap revisions. The
Comparisons page covers that trade-off in full,
including the side-by-side table and the per-partition size breakdown.
Where Ubuntu Core is the right call
Ubuntu Core is a strong fit when you want Canonical’s 12-year LTS commitment,
when strict per-app confinement via snaps and AppArmor is a product requirement,
when your team already operates a Canonical stack (Landscape for fleet
management, a brand store for distribution), or when the device has ample
storage and the size floor is an acceptable trade for signed transactional
updates with rollback out of the box. If your target instead has tight flash, a
custom SoC, and a single-purpose image, a build system such as [yoe] or Yocto
is usually the better match.
How [yoe] could assemble Ubuntu Core images
This section is a design exploration, not a plan.
[yoe]does not build Ubuntu Core images today. The point here is to show that the gap is a deliberate product choice rather than a technical wall: a snap-assembled, immutable image is files — squashfs blobs, partitions, a bootloader, and a chain of signed assertions — and[yoe]already builds partitioned, bootable disk images. What follows is the seam an implementation would most likely use.
The precedent: orchestrate, don’t reimplement
[yoe] does not reimplement dpkg to build a Debian rootfs — it shells out to
mmdebstrap, which drives
apt and dpkg in a single pass. The image assembler picks the rootfs builder by
distro family: _is_apt_distro(effective_distro) selects
_assemble_debian_rootfs() (mmdebstrap) over _assemble_rootfs() (apk), and
_create_disk_image_debian() over _create_disk_image()
(modules/module-core/classes/image.star).
An Ubuntu Core path would follow the same shape. The hard, UC-specific work —
fetching the snaps, validating them against the model, and writing the recovery
seed plus its assertion chain — is already implemented in
snapd’s image package
(image.Prepare(), backed by seedwriter), and Canonical’s
ubuntu-image is a thin wrapper
over it. The implementation move is to invoke ubuntu-image (or
snap prepare-image) as a subprocess, not to re-derive the seed format —
exactly as the Debian path shells out to mmdebstrap.
The subprocess boundary is deliberate, and it is a licensing decision as much as
an architectural one. [yoe] is Apache-2.0; snapd and ubuntu-image are
GPL-3.0. Importing snapd/image as a Go library would static-link GPL-3 code
into the [yoe] binary and make the distributed binary a GPL-3 combined work —
incompatible with keeping [yoe] Apache-2.0. Running the GPL-3 tool as a
separate process is mere aggregation, so the copyleft does not reach into
[yoe]. This is the same firewall [yoe] already relies on when it shells out
to mmdebstrap, mksquashfs, and apk-tools rather than linking them, and a
from-source .snap emitter stays clean the same way (mksquashfs as a
subprocess plus a meta/snap.yaml it writes directly).
This is also [yoe]’s strongest piece of prior art for the idea. ubuntu-image
demonstrates the seam end to end: a single binary builds both snap-based (Ubuntu
Core) and classic deb-based images, and the two builders share one
disk-assembly path — load gadget.yaml, size the rootfs, populate the boot
filesystem, write the partitions — branching only on how the rootfs is filled
(seed snaps vs. a chroot of packages). That is exactly the rootfs_fn-varies /
disk-assembly-is-common split [yoe] already uses, which is why the UC path is
additive rather than a rewrite.
The new pieces
Two new building blocks, both analogues of things [yoe] already has:
- A
snap_feed(...)— the counterpart toapt_feed(...). It pulls prebuilt snaps (base, kernel, gadget, snapd, apps) and their store assertions from a snap store, the same wayapt_feedmirrors.debpackages and their signedPackagescatalogs. Snaps that a project builds from source would come from units whose emitter writes a squashfs plusmeta/snap.yaml(a.snapemitter, sibling to the existing.deband.apkemitters). - A model input — the project supplies a model assertion (or
[yoe]generates an unsigned/“dangerous” one for development). This is the one genuinely new concept in the signing model: UC keys an image to a signed model and a brand key, where[yoe]today keys a repo to a project signing key.
The parallel assembly path
With those in place, the image assembler gains a third branch alongside the apk
and apt paths. An image declared distro = "ubuntu-core" would resolve its
rootfs_fn/disk_fn to functions that:
- Resolve the snap set from the
snap_feed(and any from-source.snapunits). - Invoke
ubuntu-image(orsnap prepare-image) as a subprocess in a container, the waymmdebstrapruns today, to fetch and validate the snaps and write the seed and its assertion chain — theubuntu-seed/ubuntu-boot/ubuntu-save/ubuntu-datacontent and the managed bootloader. - Emit the disk image through
[yoe]’s existing partition/image plumbing — the same step the apk and apt paths already use, sinceubuntu-imageshows the disk-assembly stage is independent of the packaging model.
The result slots into [yoe]’s model the way the Debian backend slots in beside
Alpine: a new distro family with its own assembler, sharing the unit graph,
content-addressed cache, and image front-end. The snap target is effectively
another distro/libc key in the existing fan-out (target = core24), so
from-source app snaps reuse [yoe]’s Ubuntu glibc backend to match the base
snap’s ABI.
What stays genuinely different
The mechanics are tractable; the reason [yoe] does not ship this is that the
path imports the parts of the snap model [yoe] was designed to avoid:
- A second update model. UC’s read-only-base-plus-writable-overlay root,
per-snap revision retention, and the resulting size floor are the whole point
of snaps — and the thing
[yoe]’s content-addressed packages plus atomic whole-image updates were built to do without. Adopting the assembly path means adopting that runtime model on the device. - Assertions and brand keys. Production UC images are gated on a signed
model and, for fleet delivery, a brand or dedicated store — a signing and
distribution model distinct from
[yoe]’s self-hosted project-key repository.