module-debian — wrapping prebuilt Debian packages
module-debian is a yoe module that wraps prebuilt Debian .deb files as yoe
units, mirroring the role module-alpine plays for Alpine. Where module-core
builds packages from upstream source, units in this module fetch a binary .deb
from a pinned Debian release, verify its SHA256 against the upstream-signed
Packages catalog, and republish it through yoe’s project repo. The unit’s
“build” is just extracting the deb’s data.tar into $DESTDIR.
The module lives at https://github.com/yoebuild/module-debian. Open it to
browse the bootstrap keyring, the in-tree Packages snapshots, or to send a PR
adding a new feed/component.
Implementation details: how Debian debs pass through yoe’s pipeline (
apt_feed, the InRelease verify path, mmdebstrap-driven image assembly, the project repo emitter) live indocs/specs/2026-05-25-module-debian.mdand the matching plan underdocs/plans/. This doc is the “when to reach for it” rubric.
When to reach for it
The same policy yoe follows for Alpine applies to Debian. The choice between the two is whether the image targets glibc (Debian) or musl (Alpine); the rest of the rubric — yoe builds the small stuff, the distro module ships the hard-to-build complexity — is identical.
- Yoe builds the easy stuff in
module-coreregardless of distro target. The samezlib,xz,expat, … source units compile against either toolchain via thecontainer = "toolchain"virtual reference. module-debianships Debian-native and hard-to-build packages. Debian-native meansdpkg,apt,debianutils,base-files,libc6/libc-bin. Hard-to-build means packages where Debian’s expertise earns its keep:openssl,openssh-server,curl,python3,clang, and the entirelinux-image-*lineage when running stock kernels makes sense.- Keep building from source anything where the build defines the product. Toolchain, kernel (when custom), bootloader, init scripts, board-specific firmware — these are not packages, they are the distribution.
Debian release coupling
The Debian suite pinned in MODULE.star (_DEBIAN_SUITE = "bookworm" at the
time of writing) must match the FROM debian:<release> line in
@module-debian//containers/toolchain-debian-13/Dockerfile. Both currently
point at bookworm.
The coupling matters for three reasons:
- glibc ABI. Source units that link against headers/libs from the toolchain
container produce binaries that need a matching glibc on the target rootfs.
Mixing
bookworm-slimheaders withtrixieruntime libs is a silent ABI mismatch. - Signing keys. Each Debian release has its own archive signing key, and the
in-tree
keys/debian-archive-keyring.gpgis whatyoe update-feedsverifies against. Bumping the suite without rotating the bootstrap keyring produces anuntrusted keyerror at firstupdate-feedsafter the bump. - Cache invalidation. Source units cache by hash; switching the toolchain
container’s
FROMtag rolls every hash through it. Plan the bump for a full rebuild cycle.
Trust chain
sources.list.d/<project>.sources
├── Signed-By: /etc/apt/keyrings/<project>.gpg
└── URIs: https://<host>/<project>/debian
apt fetch InRelease
→ gpg verify against /etc/apt/keyrings/<project>.gpg
→ REJECT if Valid-Until expired
apt fetch Packages
→ SHA256 verified against InRelease
apt fetch <pkg>.deb
→ SHA256 verified against Packages
→ install + run maintainer scripts via dpkg
The project repo is regenerated every time a unit changes; the InRelease is
re-signed each emit. Valid-Until defaults to 30 days, configurable per-project
— short enough to bound rollback windows, long enough for disconnected
development. Embedded fleets with strict security cadence may want a shorter
window; offline-tolerant fleets may want longer. The trade-off is
fleet-specific; pick a value that matches your update cadence and ability to
push fresh InRelease files when needed.
Repository URLs must be HTTPS. yoe validates this at project evaluation time; an
http:// URL in a apt_feed(...) call fails fast with a clear error. Plaintext
mirrors expose the trust chain to MITM injection — the bootstrap keyring’s job
is to verify what the mirror says, but the mirror can’t be trusted to deliver
bytes faithfully without TLS.
Maintainer playbook
The flow mirrors module-alpine’s. Inside a checked-out module-debian:
- Refresh in-tree
Packagessnapshots. Runyoe update-feedsinside the module directory. The command peeksMODULE.starfor everyapt_feed(...)call, fetches each declared suite’sInReleasefrom the pinned mirror, verifies it againstkeys/debian-archive-keyring.gpgwith Valid-Until enforcement, fetches per-archPackages.gz, decompresses, and atomically writes the result intofeeds/<component>/<arch>/Packages. Writes only — review withgit diff feeds/and commit when ready. - Push upstream. yoe’s external-module workflow (CLAUDE.md) fetches a
pinned ref on every build, so the new
Packagessnapshot needs to land on the canonical remote before the next consumer’syoe buildwill see it. - Key rotation. When Debian rotates a release signing key — typically when
a new stable ships —
yoe update-feedswill refuse the new key until its fingerprint is inkeys/allowed-fingerprints. Verify the fingerprint against https://ftp-master.debian.org/keys.html, then either editallowed-fingerprintsdirectly or useyoe update-feeds --allow-key-update=<fpr>to append it in one step.
Declaring a feed
In MODULE.star:
apt_feed(
name = "main",
url = "https://deb.debian.org/debian",
suite = "bookworm",
component = "main",
arches = ["amd64", "arm64"],
index = "feeds/main",
keyring = "keys/debian-archive-keyring.gpg",
)
Each call registers a SyntheticModule named <parent>.<component> (e.g.
debian.main, debian.contrib, debian.non-free) — matching alpine’s
alpine.main / alpine.community shape. The suite kwarg configures which
on-disk Packages file is parsed but does not appear in the module name; one
Debian suite per project, enforced at evaluation, so the suite has no
disambiguating role at the module level. Units materialize lazily as the runtime
closure references them, so a project pulling in openssh-server parses about a
thousand entries on the way to its closure — not the full 60k-entry catalog. See
Catalog and Materialization for the resolver-side mechanics (how
synthetic modules differ from real modules, lazy-Lookup contract, and the
working-set sizes the resolver operates at).
Multiple feeds compose: declaring debian.main plus security and updates
overlays (each with its own apt_feed(...) call, same suite, different
component or apt-overlay URL) gives apt-equivalent priority resolution on the
project side. The closure walker consults each in declaration order; first match
wins.
Verifying a Debian image
End-to-end verification — does the image actually boot? — runs against the
debian-base-image fixture under testdata/e2e-project/. The fixture targets
the smallest closure that boots in QEMU and accepts SSH: kernel, init,
networking, openssh-server.
prefer_modules is scoped per consuming distro, so the alpine pins in the
e2e-project PROJECT.star (xz, zstd, util-linux, curl → alpine.main)
don’t bleed into the debian closure: a debian image build resolves those names
through their distro-neutral module-core equivalents. The fixture builds out of
the box on a mixed-distro project.
cd testdata/e2e-project
# 1. Refresh the in-tree Packages files if upstream has moved since the
# cached snapshot was taken. Safe to skip on a clean check-out.
yoe update-feeds
# 2. Build the image. This pulls every artifact's .deb from the cached
# bookworm feed, builds toolchain-debian-13 on first run, installs the
# closure into the rootfs with `mmdebstrap` (apt + dpkg in one pass,
# running maintainer scripts), stages the project APT keyring + deb822
# sources file, and writes a bootable disk image. Expect ~5–10 min
# on first run (toolchain build); subsequent runs hit the cache.
yoe build debian-base-image
# 3. Boot under QEMU. The fixture's machine config forwards host port
# 2222 → guest port 22 so SSH lands without extra flags.
yoe run debian-base-image
# 4. From another terminal, connect to the running image.
ssh -p 2222 root@localhost
If apt-get update and apt-get install work from inside the booted image
against the project’s own repo (https://<feed-host>/<project>/debian), the
verification has fully closed: project repo emission, on-device APT trust
staging, and the iterate–deploy–update loop all work end-to-end.
When the build fails or the image won’t boot, the failure usually surfaces in one of these places:
mmdebstrapinside the toolchain container — postinst error in the configure log; check whether the package needs network access (see Known limitations).- Bootloader install —
extlinux/syslinux-commonmust be present in the toolchain-debian-13 Dockerfile so_install_syslinux_debiancan find/usr/lib/SYSLINUX/mbr.bin. - Init startup — kernel and systemd-sysv pull in /sbin/init transitively; if the
kernel boots but init doesn’t run, check that
init(the symlink package) is in the closure. - SSH not running on first boot — Debian’s
openssh-serverpackage enables itself via systemd preset; verify withsystemctl status sshon the device console.
Known limitations
Three structural properties of the debian backend that users will encounter regardless of yoe version. None is a bug or a transitional gap — all are deliberate trade-offs that the architecture chose, and changing any one is a substantial follow-up rather than routine work.
-
mmdebstrap --variant=custominstalls the resolved closure, not Debian’s base system. To keep images content-addressed and minimal, yoe tellsmmdebstrapto install exactly the closure yoe resolved (--variant=custom) rather than the implicit Essential / Priority:required base thatdebootstrapand the stock mmdebstrap variants pull in. An image therefore contains only what its closure names plus apt’s hard-dependency expansion — there is no rescue userland, noPriority: standardset, unless an image lists it. Three consequences follow. All are handled automatically during assembly, but they are visible in the log and shape what an image must declare:- The Essential / required userland is seeded explicitly. Debian
maintainer scripts assume
sed,grep,awk,find,gzip,login, and the rest of the Priority:required toolset are present —libc6’s own preinst callssed. Custom variant pulls none of it implicitly, so the image class seeds a fixed Essential + required baseline into every Debian image’s closure. A package whose maintainer script reaches for a tool outside that baseline must add the tool to the image. - usr-merge is established before extraction. Custom variant skips the
/bin→/usr/binmerge the normal variants set up. A setup-hook creates the merged-usr symlinks against the empty target before any package unpacks; without it theusrmergepackage’s post-hoc conversion fails inside the build chroot. - Configuration is one unordered
dpkg --install --force-dependspass. The whole closure unpacks and then configures, instead of the staged configure-essentials-first bootstrapdebootstrapperforms. The assembly log shows benignignoring pre-dependency problemwarnings (e.g. systemd Pre-Depends on alibc6that is unpacked but not yet configured) — dpkg proceeds and the configure pass resolves them. A tool provided throughupdate-alternatives(notablyawkviamawk) can be needed before its provider’s postinst registers the link, so the image class pre-stages those links. A finaldpkg-queryaudit fails the build loudly if any package is left half-configured, so a genuinely broken closure never ships as a subtly incomplete image.
- The Essential / required userland is seeded explicitly. Debian
maintainer scripts assume
-
Some upstream
.debpostinsts assume network access. yoe runsmmdebstrapunder--network=nonefor hash stability and reproducibility — a configure pass that reaches out to a DNS resolver, a metadata server, or a license-prompt download produces different output depending on what’s reachable when, which would break the content-addressed cache. Packages whose postinsts do this (cloud-initprovisioning, telemetry agents, license-prompt downloaders, a small set of enterprise-software installers) fail loudly during image assembly. The narrow set this affects isn’t appropriate for embedded images anyway; replace with a from-sourcemodule-coreunit if equivalent functionality is needed, or carry the package and provide the configuration it would have fetched via the project rootfs overlay. -
One Debian suite per project, enforced at evaluation. Every
apt_feed(...)call in a project must agree on itssuitekwarg; the resolver errors at load time if it seesbookwormandtrixiedeclared in the same project. The constraint exists because the toolchain container (@module-debian//containers/toolchain-debian-13) pins one Debian release, and source units built against that toolchain’s headers/libs can’t safely mix with prebuilt packages from a different release’s libc. Multi-suite support would require a suite axis in the toolchain cache key and parallel toolchain containers per suite — feasible but out of scope today. For most projects this is the correct constraint: a fleet runs one Debian release at a time.