How I turned a hand-tuned laptop setup into a reproducible, CI-built OS image and the dozen small things that fought back.
Why bother
I daily-drive a Framework 13 (AMD Ryzen) a 2256×1504 panel at roughly 200 DPI, which means everything runs at fractional scale. For a long time it ran Aurora DX (Universal Blue’s KDE Plasma image). Aurora was quietly doing a lot of unglamorous work for me: media codecs, hardware video acceleration, and unified updates that Just Happened in the background.
I wanted to move to a tiling-WM setup with far less UI in the way, but without giving up the things Aurora was handling for free. The obvious path is to install stock Fedora Sway Atomic and hand-tune it. I’ve done that before. The problem isn’t the initial setup; it’s rebase day.
Here’s the failure mode that pushed me to a custom image. I track Fedora releases as they land. RPM Fusion (where the patent-encumbered codecs live) lags Fedora’s general availability by days to weeks. So when a new Fedora ships and I rebase, the codec overrides fail to depsolve, and they fail on my running machine, mid-rebase. That’s the worst possible place for a build error.
A custom image moves that failure somewhere harmless. The codec swap now happens in CI. If it breaks, I get a red X on GitHub and my laptop keeps running the last good image. The work doesn’t disappear, it moves off the critical path. That single insight is the whole reason this project exists.
The result is kb3lyb-sway, built
with BlueBuild on top of Fedora Sway Atomic, published
to ghcr.io/mark-iid/kb3lyb-sway, and rebuilt nightly.
Everything below is captured in more structured form in two docs in the repo:
DESIGN.md(the why) andSETUP.md(the operational runbook).
The shape of the thing
BlueBuild takes a YAML recipe and produces an OCI image you can rpm-ostree rebase onto. The whole image is defined in
recipes/recipe.yml:
a base image, then an ordered list of modules, copy files in, layer RPMs,
install flatpaks, run scripts, enable systemd units, sign the result.
base-image: quay.io/fedora-ostree-desktops/sway-atomic
image-version: 44 # pinned; bumped by an automated PR, never floating
A few architectural decisions set the tone for everything else.
The base is pinned, never floating. image-version is an explicit Fedora
number, not latest. Fedora picking my upgrade date is exactly the thing I’m
trying to prevent. (More on how it does get bumped, safely, further down.)
Prefer $HOME over layering, and layering over overrides. This is the
ordering rule I keep coming back to. Overrides (replacing a package the base
ships) are the only category that carries a recurring maintenance cost, because
they can fail to depsolve on any future update. $HOME-scoped tools (Homebrew,
SDKMAN, JetBrains Toolbox) cost nothing at image-build time and self-update. So
anything that can live in $HOME does; anything that must be layered is layered;
and overrides are reserved for the few things with no other option (codecs, the
display manager swap).
Config is split in two. Baked-in defaults ship in the image via the files
module, enough for a fresh user to reach a working session. The configs I
actually iterate on live in a separate dotfiles repo deployed with
GNU stow. The test I use: if changing it
should require a reboot, bake it; if changing it should take effect on niri msg action reload-config, keep it in git.
The choices that define the desktop
Some decisions were quick; a few are worth calling out because they’re not the default answer.
- niri, not sway. I had no i3/sway muscle memory to preserve, and
niri’s scrollable tiling plus its built-in
xwayland-satelliteintegration matters a lot at fractional scale. The session config is baked atfiles/system/etc/niri/config.kdlas a default, then overlaid from dotfiles. - Ghostty as the primary terminal. GTK4 + Wayland-native, so it honors the
1.35 scale directly with no DPI dance.
footstays installed as a tiny, dependency-light fallback that always launches. - greetd + tuigreet for the greeter, a text login that fits the minimal-chrome goal.
- Evolution with the Microsoft 365 (Graph) backend, not EWS. This one is
time-sensitive: Microsoft begins blocking Exchange Web Services in Exchange
Online on October 1, 2026.
evolution-ewsships a newer Graph backend; that’s the one to use. New-mail alerts route through mako via Evolution’s built-in notification plugin.
The full package and flatpak list lives in the recipe; the interesting parts are why certain packages needed special handling, which is the next section.
The parts that fought back
This is the real content of the project. Anyone can write a package list. The value is in the dozen small traps, each of which cost real debugging time.
Codecs are the fragile heart, so they’re isolated
The codec swap is the single most likely thing to break on a version bump, so it
lives on its own in
recipes/common/codecs.yml:
RPM Fusion free/nonfree, an ffmpeg → RPM Fusion ffmpeg swap, and
mesa-va-drivers-freeworld for hardware VA-API decode.
Because it’s the fragile one, there’s a second recipe,
recipes/codec-test.yml,
that builds just the codec module on top of the base. When a Fedora bump lands
and RPM Fusion hasn’t caught up, I find out from that small, fast build instead of
from a full image build, or worse, from my laptop.
HiDPI: the part most likely to go wrong
Target is scale 1.35 on the internal panel. Wayland-native apps render sharp at
that scale via fractional-scale-v1. The entire problem is XWayland.
Electron apps (VS Code, Slack) default to XWayland and look soft until forced onto
native Wayland with --enable-features=UseOzonePlatform --ozone-platform=wayland
via flatpak overrides for the flatpak ones, *-flags.conf for layered ones.
For the remaining X11 stragglers, the trick is toolkit-side scaling so they render
at native resolution and scale themselves rather than being bitmap-scaled by the
compositor:
- GTK3:
GDK_SCALE=2andGDK_DPI_SCALE=0.675(GDK_SCALE is integer-only; the pair multiplies out to an effective 1.35) - Qt:
QT_SCALE_FACTOR=1.35
The gotcha that cost me an evening: these must live in
~/.config/environment.d/ or the compositor’s exec chain. Putting them in
.zshrc does nothing, apps launched from a launcher never source a shell rc
file. Obvious in retrospect; invisible while you’re staring at a blurry Slack.
COPR dependency resolution: plain vs scoped packages
Ghostty isn’t in Fedora’s repos yet, so it comes from the scottames/ghostty
COPR. The naïve way to write that is BlueBuild’s scoped form (repo: copr:…),
which restricts dependency resolution to only that COPR. Ghostty pulls zlib-ng
and gtk4-layer-shell as deps, and zlib-ng lives in Fedora’s repos, not the
COPR. So the scoped form fails with “nothing provides zlib-ng”.
The fix: enable the COPR, then list ghostty as a plain package. Now deps
resolve from Fedora and the COPR together. The same pattern applies to
wl-clip-persist from another COPR. Both are documented inline in the recipe so
future-me doesn’t re-learn it.
The dead portal that silently broke dark mode
This is my favorite bug because the symptom is so far from the cause. greetd
launches niri as a bare niri --session, not via niri.service. That means
graphical-session.target never activates. And because xdg-desktop-portal has
Requisite=graphical-session.target, the portal never starts.
A dead portal silently breaks the dark-mode color-scheme signal (so Electron and
libadwaita apps stay light), file-chooser portals, and screencasting, none of
which obviously points back at “the login target didn’t come up.” The fix (in the
dotfiles) is a tiny oneshot user unit that BindsTo/Before
graphical-session.target, started from niri’s spawn-at-startup, mirroring what
niri.service would have done.
Locking, blanking, and white-on-white PAM prompts
The lock screen is gtklock, not swaylock, swaylock is a bare ring with no field, labels, or usable fingerprint UX. Two lessons stuck:
- gtklock launched by a systemd service doesn’t inherit niri’s
environment{}, so it needsEnvironment=GTK_THEME=Adwaita:darkset explicitly, otherwise the PAM prompts render white text on a white background. - An earlier theory that gtklock’s live clock re-woke the panel turned out to be a
misdiagnosis. A verified test showed the panel still blanks on input idle even
while an animation repaints every second, blanking is driven by
ext-idle-notify, not by surface damage. Worth recording precisely because the wrong version was plausible.
Clipboard that disappears
wlroots/niri drop the active selection when the source window closes. Copy a URL
from a short-lived dialog, the dialog closes, paste into Firefox, nothing.
wl-clip-persist keeps the last selection alive. wl-clipboard + cliphist
cover copy and history but not live persistence; they’re different problems.
Discord: the flatpak that loads but never appears
Discord’s flatpak uses a zypak sandbox that breaks the Wayland splash → main-window
handoff under niri. The client loads, but never maps a window. So Discord is the
one GUI app installed natively into $HOME (by the dotfiles bootstrap) instead of
as a flatpak, a deliberate exception to the “GUI apps are flatpaks” rule.
The console font that kept resetting
A baked FONT= default for the big HiDPI console kept getting masked by the
installer’s /etc/vconsole.conf after vconsole-setup ran. The fix is a small
oneshot service,
kb3lyb-console-font.service,
that re-applies the font after vconsole-setup.
Automatic power profiles
Aurora handled this; the minimal base doesn’t. A
kb3lyb-power-profile.timer
plus a udev rule flips power-profiles-daemon: performance on AC, balanced on
battery, power-saver under 20%.
Fingerprint vs the login keyring
Fingerprint auth is wired up through authselect in
files/scripts/authselect-fingerprint.sh.
But fingerprint login never captures a password, so pam_gnome_keyring can’t
unlock the login keyring from it. The chosen resolution: a passwordless login
keyring, relying on LUKS full-disk encryption as the real secret boundary.
Keeping it current without letting Fedora choose the date
The update story replaces what Aurora’s uupd did in one unit, now split across
several timers:
- Nightly rebuild against the pinned base, via
.github/workflows/build.yml, so upstream Fedora updates flow through with zero action from me. - A scheduled version-bump PR.
.github/workflows/fedora-version-bump.ymldetects a new Fedora Atomic release and opens a PR bumpingimage-version. I merge it. Automatic detection, manual timing. - On the host,
rpm-ostreed-automatic.timerstages OS updates, aflatpak-update.timerrefreshes system flatpaks (with anOnBootSec=3mintrigger so a laptop that’s off overnight doesn’t miss the daily window), and a per-userbrew-upgrade.timerkeeps$HOMEHomebrew current.
Everything is signed with cosign; the public
key is cosign.pub
in the repo.
The install that could have wiped a live machine
The most consequential section of the whole design isn’t in the image at all, it’s the sequencing of the clean install, because there’s no rollback once the internal drive is wiped.
Two things earned their own warnings:
anaconda-iso is unattended by default and will silently wipe the first disk it
finds. With no kickstart, bootc-image-builder --type anaconda-iso emits an
installer that installs to the first disk it sees, no prompt, no target
selection. This is not hypothetical: an early build that trusted a wrong claim of
interactivity overwrote a live machine’s internal NVMe exactly this way. The fix
lives in
vm/iso-config.toml,
whose empty [customizations.installer.kickstart] block forces Anaconda to stop
at the interactive hub and wait.
vm/build-iso.sh
refuses to run if that config is missing.
Test on real hardware before wiping, for a week. A VM can’t test the fingerprint reader, the 200 DPI panel at 1.35 scale, the amdgpu PSR hang that a kernel arg works around, HDMI-out, bluetooth audio, or suspend/resume. So the plan is a full install onto an external USB NVMe (in a 10 Gbps enclosure, not a flash drive, which would produce a false negative), daily-driven for a week, with the internal drive untouched. Only then does the internal drive get wiped.
And the backup rules that are easy to forget at 11pm:
- Back up the LUKS header of the encrypted backup volume, and store that copy somewhere else. A corrupted header means the backup is unrecoverable, a bad way to discover a single point of failure was a single point of failure.
- The atuin encryption key (
~/.local/share/atuin/key) is unrecoverable if lost; without it the synced shell history becomes undecryptable. Back it up first. - Nothing with a token, key, or credential goes into the public image or the dotfiles repo. Those move by hand, encrypted, to storage I control.
Iterating locally used a VM, not GitHub Actions, as the edit-compile-test loop:
vm/build-qcow2.sh,
vm/boot-check.sh,
and vm/retest.sh
build the image to a qcow2, boot it headless, and confirm niri renders over VNC
before anything touches real hardware.
Lessons that generalize
Stripping away the Framework-specific detail, a few things I’d carry to any image like this:
- Move fragile work off the critical path. The entire justification for the image is that codec breakage happens in CI, not mid-rebase on the daily driver. Find your equivalent of “codecs” and get it building somewhere you don’t care if it fails.
- Isolate and smoke-test the fragile module. The dedicated codec-only recipe pays for itself the first time a version bump breaks it.
- Have a layering hierarchy and follow it.
$HOME> layer > override, because only overrides carry recurring cost. Every deviation should be a documented, deliberate exception. - Environment variables for GUI apps do not belong in shell rc files. They go
in
environment.dor the compositor exec chain. This burns everyone once. - Write down the misdiagnoses, not just the fixes. The gtklock-clock and the dead-portal notes are valuable precisely because the wrong explanation was plausible. A design doc that only records the final answer invites re-deriving the wrong one.
- The dangerous step is the installer, not the image. An unattended installer that picks its own disk is the one thing in this whole project that can destroy data. Treat it with the paranoia it deserves.
The image will keep evolving, but the point was never a finished artifact. It was to make a moving target reproducible, and to make the failures land somewhere I can afford them.
Repo: github.com/mark-iid/frameworkimage. Design rationale in DESIGN.md, operational runbook in SETUP.md.