Handbook · Chapter 6 of 12 · 14 min read
Application payload: Flatpaks and the offline-docs module
The OCI image owns /usr. Apps live in /var/lib/flatpak — and ostree/bootc reset /var per deployment: anything you put there in the Containerfile is silently absent on the installed system. So "shipping apps" in a bootc distro is really a question of when and through which channel /var/lib/flatpak gets populated. Margine uses three tiers plus one supporting subsystem (system Flatpak overrides), and the same machinery powers the offline documentation mirror.
6.1 Three delivery tiers
| Tier | Mechanism | When the user gets the app | Cost |
|---|---|---|---|
| BAKE (~29 apps: browser, mail, office, GNOME suite) | flatpak install into the installer image at OCI build time, then Anaconda %post --nochroot rsync into the target's /var/lib/flatpak |
Already on the desktop at first login | +5–10 min Anaconda "Running post-install scripts", 0 GB extra ISO logic |
| DEFER (heavy creative: GIMP, Inkscape, darktable, OBS, Reaper) | /usr/share/flatpak/preinstall.d/*.preinstall + flatpak-preinstall.service at first boot |
5–15 min after first boot, with desktop notifications | Background bandwidth; needs UX feedback |
| On-demand (gaming stack) | ujust margine-gaming recipe |
When the user asks | Interactive; may layer host RPMs |
The split criterion is stated in the build script itself:
# BAKE (kickstart %post --nochroot at install time, ~22 apps):
# Browser, mail, password, office, image+pdf+video viewer,
# GNOME productivity suite. Apps the user expects to find ALREADY
# INSTALLED on the desktop the first time they log in.
#
# DEFER (.preinstall files + flatpak-preinstall.service at first
# boot, ~12 apps):
# Heavy creative apps (GIMP, Inkscape, darktable, OBS, Reaper,
# ...) the user doesn't need in the first 10 min after first
# login. flatpak-preinstall.service downloads them in background.
margine-image/build_files/20-flatpaks/install.sh (lines 14–28). The boundary is UX, not size alone: "first 10 minutes after login" defines BAKE.
6.2 One list, three consumers
The BAKE list is a single flat file, installer/flatpaks-base, read by (a) the OS-image build (copied to /usr/share/margine/installer-flatpaks-base for the kickstart), (b) the BIB installer-image build (installer/build.sh), and (c) the Titanoboa live-env build (live-env/src/flatpaks). One edit propagates everywhere.
app.zen_browser.zen
org.mozilla.thunderbird_esr
com.bitwarden.desktop
org.libreoffice.LibreOffice
...
# fm.reaper.Reaper — INTENTIONALLY EXCLUDED from BAKE 2026-06-05:
# Reaper's apply_extra script downloads the proprietary binary at
# install time, which fails inside the podman build container with
# "apply_extra script failed, exit status 256"
...
com.github.tchx84.Flatseal # Flatpak permissions GUI
io.github.flattool.Warehouse # Flatpak management GUI
margine-image/installer/flatpaks-base (trimmed). Note the two embedded decisions: apps whose apply_extra hook downloads proprietary blobs may not survive a container build (Reaper stays DEFER-only), and inline # comments are allowed — which caused a real failure, below.
Lesson — inline comments become Flatpak IDs. Symptom:
flatpak installfails withInvalid id #: Name can't start with #(build #27075455521). Root cause:grep -v '^#'only strips whole-line comments;Flatseal # Flatpak permissions GUIpasses#,Flatpak,permissions,GUIas literal app IDs. Fix:APPS=$(grep -v '^[[:space:]]*#\|^[[:space:]]*$' "$LIST_PATH" \ | sed -E 's/[[:space:]]*#.*$//; s/^[[:space:]]+//; s/[[:space:]]+$//' \ | grep -v '^$')
margine-image/installer/build.sh(lines 52–54). Same sed replicated inlive-env/src/build.sh.
6.3 BAKE: build-time install, install-time rsync
Why not flatpak install inside the kickstart?
Margine's first approach ran flatpak install --system directly in Anaconda %post. It failed silently on a fresh install.
Lesson — install-time downloads of a 5 GB set are fragile. Symptom: 2026-06-04 fresh install completed "successfully" but the apps were missing. Root cause: probably
/tmptmpfs OOM in the installer environment — the BAKE set is ~5 GB and the installer's/varis RAM-backed. Network blips produce the same silent partial result (--noninteractivereturns 0 on partial failure). Fix: move the download to OCI build time (the CI runner has real disk). A dedicated installer image is built with the Flatpaks already in its/var/lib/flatpak, and the kickstart degrades to a pure local rsync. Documented in the kickstart itself:# Previously this %post also did `flatpak install --system` at # install time. That failed silently on the 2026-06-04 fresh install # (probably /tmp tmpfs OOM in installer env, since the BAKE set is # ~5 GB). Switching to installer-image moves that download to build # time (CI runner has plenty of disk) so install-time stays fast + # reliable.Originally documented inline in the BIB kickstart (
disk_config/iso-gnome.toml, now deleted); the same install-time-vs-build-time rationale carries over tolive-env/src/anaconda/post-scripts/install-flatpaks.ksand ADR-0008 §4.
Installing Flatpaks inside a container build
flatpak install inside podman build needs two environment shims, because bwrap (used by apply_extra) expects a real /root and a writable /proc/sys:
# Copied straight from Bazzite's installer/build.sh — without these the
# apply_extra step (used by Reaper, Steam, openh264 for binary blobs)
# fails with:
# F: Unable to provide a temporary home directory in the sandbox:
# Unable to open path "/var/roothome": No such file or directory
# bwrap: cannot open /proc/sys/user/max_user_namespaces:
# Read-only file system
mkdir -p "$(realpath /root)"
mount -o remount,rw /proc/sys
flatpak remote-add --if-not-exists --system flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
...
flatpak install --system --noninteractive --or-update flathub $APPS
margine-image/installer/build.sh (lines 21–31, 44–45, 66). The build also requires podman build --cap-add sys_admin --security-opt label=disable (set in build-disk.yml) — bwrap needs user namespaces.
The rsync into the target
ostree deployments mount their own /var; the kickstart locates the freshly written deployment and copies the populated tree in:
DEPLOY_DIR=$(ls -d /mnt/sysimage/ostree/deploy/default/deploy/*.0 2>/dev/null | head -1)
...
mkdir -p "$DEPLOY_DIR/var/lib"
rsync -aAXUHKP --filter='-x security.selinux' /var/lib/flatpak "$DEPLOY_DIR/var/lib/"
sync
margine-image/live-env/src/anaconda/post-scripts/install-flatpaks.ks (lines 26–40). This %post is deliberately not --erroronfail: the bake is quality-of-life, and every BAKE app is also in the DEFER list (next section), so a failed rsync degrades to a first-boot download — never a bricked install. bootc switch keeps --erroronfail because that is install-critical.
Lesson — copy POSIX xattrs, strip SELinux labels. Symptom: baked Flatpaks can fail to launch with AVC denials on the installed system. Root cause:
rsync -Xcopies all xattrs, includingsecurity.selinuxlabels minted in the installer environment — wrong contexts for the target filesystem. Fix: Bluefin's verified-in-production incantation — keep-AX(ACLs + xattrs, needed by Flatpak's deploy metadata) but exclude the SELinux namespace; ostree's finalize relabels the target correctly:rsync -aAXUHKP --filter='-x security.selinux' /var/lib/flatpak "$DEPLOY_DIR/var/lib/"
install-flatpaks.ksline 39; rationale inmargine-fedora-atomic/docs/adr/0008-titanoboa-migration-plan.md§4. (The earlier BIB kickstart used plain-aAXUHK --open-noatime; the Titanoboa path adopted the filter as the invariant, and that BIB kickstart —iso-gnome.toml— has since been deleted.)
One more guard in the Titanoboa live environment: the live session and the installer share /var/lib/flatpak, so the baked set is bind-mounted read-only to keep the live user from tainting it before the rsync (var-lib-flatpak.mount, Options=bind,ro, live-env/src/build.sh lines 173–188 — Bazzite pattern).
6.4 DEFER: declarative first-boot via preinstall.d
Flatpak 1.16 introduced an upstream declarative preinstall API: .preinstall keyfiles under /usr/share/flatpak/preinstall.d/ are consumed by flatpak-preinstall.service at boot. This lives in /usr — image-owned, survives every update, no kickstart involved. Margine generates one file at image build:
mkdir -p /usr/share/flatpak/preinstall.d
{
...
for app in \
org.gimp.GIMP \
org.inkscape.Inkscape \
org.darktable.Darktable \
com.obsproject.Studio \
app.zen_browser.zen \
...
it.mijorus.smile ; do
echo "[Flatpak Preinstall $app]"
echo "Branch=stable"
echo "IsRuntime=false"
echo
done
} > /usr/share/flatpak/preinstall.d/margine-defaults.preinstall
margine-image/build_files/20-flatpaks/install.sh (lines 72–154, trimmed). Two design points:
- Belt and suspenders: every BAKE app is also listed here. If the install-time rsync silently fails,
flatpak-preinstall.servicecatches the gap at first boot (5–15 min wait instead of instant, but never "apps missing"). On a successful BAKE the entries are no-ops — flatpak skips already-installed refs. - The legacy uBlue mechanism is dead:
/etc/ublue-os/system-flatpaks.listis silently ignored on current Bluefin DX. The build deletes it (rm -f, line 162) to prevent confusion. If you derive from a Universal Blue image, targetpreinstall.d, not the old list.
6.5 Notify-and-install-later: first-boot UX for DEFER
A background download with no feedback reads as "broken install". Margine ships an XDG autostart notifier that watches flatpak-preinstall.service and posts GNOME notifications at start and completion:
svc_state() {
local s
s=$(systemctl is-active flatpak-preinstall.service 2>/dev/null) || true
[[ -z "$s" ]] && s=unknown
printf '%s' "$s"
}
...
case "$initial" in
active|activating|reloading)
notify-send --app-name="Margine" --icon="org.gnome.Software" --urgency=low \
--hint=string:desktop-entry:io.github.kolunmi.Bazaar \
"Margine sta installando alcune app aggiuntive" "..."
# Poll for completion. Cap at 60 min so we don't sit here forever
deadline=$(( $(date +%s) + 3600 ))
margine-image/build_files/system_files/usr/libexec/margine-first-boot-status (lines 63–92, trimmed). Idempotent via a ~/.cache/margine/first-boot-notified marker; if the service already finished before first login, it stays silent. A failed final state posts a recovery hint (systemctl restart flatpak-preinstall.service).
Lesson —
systemctl is-activeon anactivatingunit prints and fails. Symptom: no notification on the 2026-06-06 fresh install; log showsunexpected initial state: activating / unknown. Root cause:is-activeexits 3 foractivatingwhile still printingactivating. The naivesystemctl is-active ... || echo unknowntherefore yields TWO lines (activating\nunknown), which matches nocasearm. Fix: capture stdout, ignore the exit code, fall back tounknownonly when stdout is empty — thesvc_statehelper above.
Lesson — GNOME 50+ skips autostart entries with
X-GNOME-Autostart-Phase. Symptom: notifier never ran at login ("non ho visto nessun messaggio", 2026-06-04). Root cause: gnome-session no longer manages session phases; entries carrying the key are warned about and skipped entirely. Fix: delete the key from the.desktopfile — see the warning comment insystem_files/etc/xdg/autostart/margine-first-boot-status.desktop(lines 12–19).
6.6 On-demand: ujust margine-gaming
The heaviest payload (Steam, Lutris, Heroic, Bottles, RetroArch + host gamescope/vkBasalt) is not preinstalled at all. A dedicated image variant existed and was retired 2026-06-06; the supported path is two interactive recipes, each with a symmetric -remove. The default, ujust margine-gaming, installs the gaming launchers as Flatpaks system-wide and layers only the two gaming-only RPMs (gamescope + vkBasalt) via rpm-ostree. For maximum Proton/Wine compatibility (anti-cheat, VR, NVIDIA-proprietary + Mesa side-by-side), ujust margine-gaming-native instead layers Steam + Lutris + RetroArch as native RPMs — the full 32-bit dependency closure is baked into the base image so the layering resolves offline. The Flatpak recipe below is the default path:
flatpak install --system -y --or-update flathub \
com.valvesoftware.Steam \
net.lutris.Lutris \
com.heroicgameslauncher.hgl \
...
# gamescope + vkBasalt are the only RPMs strictly gaming-only.
margine-image/build_files/60-custom.just (lines 150–162, trimmed). The recipe prints the trade-off before asking confirmation: layered RPMs branch the deployment from the base OCI image, add ~30–60 s per bootc upgrade, and can conflict when upstream relocates a file. Keeping this opt-in keeps the default image unbranched.
6.7 System Flatpak overrides
Sandboxed apps cannot see host paths the image wants them to read. Margine writes system-level overrides (/var/lib/flatpak/overrides/) from root services, and documents per-user overrides in recipes:
- Global, written by
docs-refresh(next section):flatpak override --system --filesystem=/var/lib/margine/offline-docs:ro— grants every Flatpak read access to the docs mirror, so it keeps working if the user swaps Zen for another Flatpak browser. - Per-user, suggested by
ujust margine-gaming:flatpak override --user --filesystem=xdg-config/MangoHud:ro com.valvesoftware.Steam— lets Flatpak Steam read the host MangoHud config.
Rule of thumb: image-level guarantees → --system override written by a unit; user preference → --user override in a recipe.
6.8 The offline-docs module, end-to-end
A self-contained case study tying the above together: ship the project documentation offline, keep it fresh, and make it readable from a sandboxed browser.
6.8.1 Build: fetch + rewrite for file://
build-offline-docs.py crawls a fixed route list from the live docs site and rewrites each page for offline use: strip <script>, inline stylesheets, drop preload/prefetch/preconnect hints, and convert links — same-host /docs/* links become relative paths into the mirror, other root-relative URLs become absolute back to the live site:
ROUTES = [
"/docs",
"/docs/what-is-margine",
...
"/docs/faq",
]
def inline_or_remove_link(match, base_url):
tag = match.group(0)
rel = (attr_value(tag, "rel") or "").lower()
href = attr_value(tag, "href")
if "stylesheet" in rel and href:
css_url = urljoin(base_url, href)
css = rewrite_css_urls(fetch_text(css_url), base_url)
return f'<style data-margine-offline="stylesheet">\n{css}\n</style>'
if "modulepreload" in rel or "preload" in rel or "prefetch" in rel or "preconnect" in rel:
return ""
margine-image/build_files/50-branding/build-offline-docs.py (lines 17–34, 95–106, trimmed). It also writes a manifest.txt and a stamp file (epoch seconds) — the stamp is how runtime decides whether the image seed is newer than the runtime mirror.
6.8.2 Seed in /usr at image build
The branding stage installs the builder itself into the image and runs it, so build and runtime use the exact same fetch/rewrite logic:
install -Dm0755 /ctx/50-branding/build-offline-docs.py /usr/libexec/margine/build-offline-docs
python3 /usr/libexec/margine/build-offline-docs \
--base-url "$MARGINE_DOCS_BASE_URL" \
--output-dir "$OFFLINE_DOCS_DIR" # /usr/share/margine/offline-docs
...
ln -sf ../margine-docs-refresh.timer \
/usr/lib/systemd/system/timers.target.wants/margine-docs-refresh.timer
ln -sf ../margine-docs-refresh.service \
/usr/lib/systemd/system/multi-user.target.wants/margine-docs-refresh.service
margine-image/build_files/50-branding/install.sh (lines 332–346, trimmed). Units are enabled with build-time wants-symlinks (no systemctl enable at runtime needed). CI validates the seed before push: 14+ index.html files, no live JS/CSS, no root-relative links (build.yml validator §A.4.bis).
6.8.3 Runtime: seed, grant, refresh
docs-refresh (run at boot by the service, periodically by the timer) does three ordered jobs — the first two work offline:
- SEED: if
/var/lib/margine/offline-docsis missing or itsstampis older than the/usrseed (e.g. right after abootc upgradeshipped fresher docs), copy the seed into/var. Pure localcp— the mirror exists seconds after first boot, network or not. - FLATPAK ACCESS:
flatpak override --system --filesystem="${DOCS_DIR}:ro". Without it, a Flatpak browser opening afile://URI gets only that single file via the portal — the page renders, every relative link is dead. - REFRESH: gate on a 10 s
curl ${BASE_URL}/healthz, then rebuild into${DOCS_DIR}.newwith the shipped builder and sync in. Offline → keep current copy, exit 0 (not a failure).
The service is a tightly sandboxed oneshot: ProtectSystem=strict with writes confined to StateDirectory=margine plus ReadWritePaths=/var/lib/flatpak/overrides (for step 2), empty CapabilityBoundingSet, ProtectHome, NoNewPrivileges (system_files/usr/lib/systemd/system/margine-docs-refresh.service). Deliberately no DynamicUser — it would relocate state to /var/lib/private (0700), unreadable by users' browsers. The timer is monotonic (OnBootSec=10min, OnUnitActiveSec=24h, RandomizedDelaySec=1h) because monotonic timers re-arm at every boot — a laptop that is never up 24 h still refreshes ~10 min after each boot; Persistent= would be a no-op since it only applies to OnCalendar=.
Lesson — never swap a directory a Flatpak sandbox has bind-mounted. Symptom: after a background refresh, already-running Flatpak browsers show "File not found" on every docs click until the app restarts. Root cause: Flatpak bind-mounts the override path at app start. The original refresh swapped the directory (
mvaway +rm -rf+ rename new into place) — the sandbox keeps the bind mount on the now-emptied old inode. Fix: sync into the existing directory; rsync replaces each file atomically (tmpfile+rename) and--checksumleaves unchanged files untouched, so live readers never see a partial mirror:sync_in() { chmod -R a+rX "$1" mkdir -p "$DOCS_DIR" rsync -a --delete --checksum "$1"/ "$DOCS_DIR"/ rm -rf "$1" }
system_files/usr/libexec/margine/docs-refresh(lines 38–51).
6.8.4 Open: offline-first launcher
docs-open (behind margine-documentation.desktop) opens the /var mirror instantly with no blocking network probe; fallbacks are live site (3 s healthz) then the /usr seed:
if [[ -f "${VAR_DIR}/docs/index.html" ]]; then
exec xdg-open "file://${VAR_DIR}/docs/index.html"
fi
if curl -fsS --max-time 3 "https://margine.the-empty.place/healthz" >/dev/null 2>&1; then
exec xdg-open "$ONLINE_URL"
fi
if [[ -f "${SEED_DIR}/docs/index.html" ]]; then
exec xdg-open "file://${SEED_DIR}/docs/index.html"
fi
system_files/usr/libexec/margine/docs-open (lines 25–38). Why never the /usr seed first: Flatpak reserves /usr — no override can ever expose it to a sandbox, so the seed only works for non-Flatpak browsers. The whole reason the /var mirror exists is to give Flatpak browsers a path they are allowed to read.
Alternatives & other distros
- Bluefin / Aurora (Universal Blue): upstream Flatpak
preinstall.d(Flatpak 1.16) for system apps, plus a Homebrewsystem-flatpaks.Brewfilefirst-boot path for some apps. Trade-off: zero installer complexity, but everything downloads at first boot — empty desktop for the first minutes. (Margine BAKEs DistroShelf directly instead of going through brew — see the comment ininstaller/flatpaks-base.) - Bazzite: the installer-image bake Margine copied (
install-flatpaks.ksrsync) plusujust install-*recipes for optional apps. Trade-off: best first-login UX; costs a dedicated installer image build and 5–10 min of Anaconda %post. - Fedora Silverblue/Workstation stock: no preinstall; GNOME Software shows Flathub (filtered) and Fedora Flatpaks. Trade-off: zero image complexity, user assembles everything.
- GNOME Software deploy lists /
org.gnome.software.first-run+ distro EULA-style curated lists: declare apps via GSettings/flatpak-repofiles and let Software prompt. Trade-off: discoverable and consentful, but not unattended. - Endless OS: Flatpaks fully baked into the disk image itself (their
/varis part of the image build via eos-image-builder). Trade-off: enormous images; perfect offline story — their target market. - openSUSE Aeon (MicroOS Desktop):
tikinstaller + first-bootflatpak installof a curated set. Trade-off: simple, network-dependent first boot. - Vanilla OS (ABRoot): apps via the
apx/vsolayer and Flatpak by default; system images stay app-free. Trade-off: clean separation, no offline-first option. - NixOS:
services.flatpak.enableplus the communitynix-flatpakmodule for declarative app lists; or skip Flatpak and declare everything as Nix packages. Trade-off: fully declarative and reproducible; outside the Flathub runtime-dedup model. - ChimeraOS / SteamOS: the primary app (Steam) is baked into the read-only image; Flatpak relegated to extras on the user partition. Trade-off: appliance-grade for one app, generic apps second-class.
- For offline docs specifically: most distros ship none (online wikis), Debian-likes ship
-docpackages into/usr/share/doc(works, but unreadable from sandboxed browsers — exactly the problem Margine's/varmirror + system override solves), and GNOME ships Yelp with local Mallard help (no sandbox issue, but a separate authoring toolchain).
Takeaways
/varis per-deployment: app payload is a delivery pipeline (build-time bake → install-time rsync → first-boot preinstall → on-demand recipe), not a Containerfile line.- Make every tier a fallback for the previous one — BAKE apps duplicated in
preinstall.dturn silent failures into a 15-minute delay instead of missing apps. - Downloads belong at build time (CI disk, retries, logs), not install time (tmpfs, silent partial failure).
- Strip
security.selinuxwhen rsyncing/var/lib/flatpakacross environments; let ostree finalize relabel. - Anything a Flatpak must read lives outside
/usr, gets a--systemoverride, and must be refreshed in place — sandboxes hold bind mounts on the directory inode they started with.