A custom Fedora Atomic image based on Bluefin DX, optimized for development on a Framework Intel laptop.
Bluefin DX is itself built on Fedora Silverblue — Fedora's atomic,
immutable desktop variant. The base OS lives on a read-only ostree commit;
bootc layers container images on top of it, and updates replace the whole
image atomically instead of patching packages in place. That gives this
project instant rollback (bootc rollback), an image that's identical on
every machine it's deployed to, and a build that's just a Containerfile —
no snowflake state to drift out of sync.
This repo also works as a template for the general workflow: define your
desired system as a Containerfile (or fragments, as below) layered on any
bootc-enabled Fedora Atomic base, build it with podman/buildah, test it
in a throwaway container or VM before trusting it, and ship it via
bootc switch/bootc update. The same pattern extends to CoreOS, other
ublue-os images, or a from-scratch atomic image of your own.
- Base:
ghcr.io/ublue-os/bluefin-dx:stable— GNOME + dev tooling baseline (Fedora 44) - Shell: fish (default), with starship prompt
- Terminal: Alacritty, Zellij
- Editors: Emacs, Neovim
- Dev tools: gcc/make/gdb (Development Tools group), pandoc, aspell, fd, bat, eza, zoxide, fzf, jq, yq, httpie, zellij
- Browsers: Firefox, Nyxt
- Apps: 1Password
- Fonts: JetBrains Mono Nerd Font, Cascadia Code, Inter — with tuned subpixel rendering
- Framework extras: thermald, fprintd (fingerprint reader), powertop
The point of building on an atomic, immutable base isn't just rollback safety —
it's that the entire system is described as version-controlled config, which
means an AI coding agent (or you, moving fast) can propose a change, and that
change can be linted and tested before it ever touches a machine you actually
boot into. Nothing is installed by hand; if it's not in Containerfile.d/ or
config/files/, it doesn't exist in the next build. That constraint is what
makes the loop below safe to run unattended: every change is a diff, every
diff is checked twice, and the worst case is bootc rollback.
flowchart TD
A[Edit Containerfile.d fragment or config/files] --> B["just assemble"]
B --> C["just lint (ShellCheck)"]
C --> D["just build && just test (ShellSpec, throwaway container)"]
D -- fails --> A
D -- passes --> E[git push]
E --> F["CI: re-assemble, diff-check, lint, build, ShellSpec"]
F -- fails --> A
F -- passes on main --> G["image pushed to ghcr.io/iwillig/dev-linux"]
G --> H["sudo bootc update && sudo reboot"]
H --> I{Working well?}
I -- yes --> A
I -- no --> J["sudo bootc rollback && sudo reboot"]
J --> A
Every change gets tested twice before it's live: once locally against a
throwaway container, once more in CI against the exact image that would ship.
The only step that isn't automatically checked is bootc update itself on
real hardware — and even that is one command away from undone.
The Containerfile is generated from small, purpose-scoped fragments that get
assembled, tested, and shipped by CI. The pieces:
Each fragment layers one concern on top of ghcr.io/ublue-os/bluefin-dx:stable,
numbered so they apply in a predictable order:
| Fragment | What it adds |
|---|---|
000-header |
Base image + tag args (bluefin-dx:stable) |
010-hardware |
Framework-specific packages: thermald, fprintd (fingerprint reader), powertop |
020-devtools |
Compiler toolchain (development-tools group), Emacs, Neovim, tmux, pandoc, LaTeX, fish, Alacritty, Firefox, CLI staples (ripgrep, fzf, bat, eza, jq, gh, ShellCheck, …), and dotfiles tooling (stow, gitleaks) |
030-cli-tools |
Tools not packaged for Fedora: zellij, lazygit, fastfetch, starship, the whis voice-to-text CLI, trufflehog secret scanner |
040-browsers |
Nyxt (Lisp-extensible browser), installed from its AppImage tarball |
050-package-managers |
Homebrew and SDKMAN!, installed into /var so they survive bootc updates and are writable by the wheel group without sudo |
060-languages |
Node/npm-based tooling (Claude Code CLI, TypeScript + LSP, the Pi coding agent), Clojure, and Rust (Fedora's rustc/cargo, not rustup) |
070-apps |
Handy and Whis desktop — push-to-talk speech-to-text GUIs |
080-gis |
Spatial/GIS stack: GDAL, Mapnik, QGIS, GRASS, PROJ, GEOS, SpatiaLite |
090-wine |
Wine + winetricks for running Windows binaries |
100-fonts |
JetBrains Mono, Cascadia Code, Noto Emoji, Inter, and the Nerd Font-patched JetBrains Mono |
110-config |
Copies config/files/ into the image root and enables the first-boot 1Password install service |
120-sway |
Sway tiling WM stack (waybar, wofi, mako, kanshi, swaylock/idle/bg) alongside GNOME — GDM picks it up automatically as a login session |
121-elementary-theme |
Builds the elementary GTK stylesheet from source (not packaged for Fedora) |
125-llama-cpp |
Local LLM inference via prebuilt llama.cpp CPU binaries, pinned to a specific release tag |
Run just assemble to concatenate these into the real Containerfile (the
generated file carries a header telling you not to edit it directly). CI
re-runs just assemble and fails the build if the checked-in Containerfile
doesn't match — so the fragments are always the source of truth.
Mirrors the target root filesystem (etc/, usr/share/) and gets COPY'd
wholesale into the image by 110-config.containerfile. This is where
declarative config lives instead of RUN sed/echo hacks: fish integration
snippets for Homebrew/SDKMAN, fontconfig tuning, GTK settings, the kanshi
output-switching rules, the full waybar config (modules + CSS tokens), sway
keybindings, swaylock/gammastep config, and the desktop wallpaper.
config/files/ (above) only covers system-wide config — the stuff baked
into the public image at /etc and /usr/share. It deliberately never
holds anything personal or secret, because this repo's image is pushed
public to GHCR.
Personal, per-user config (editor settings, terminal themes, shell
aliases, API tokens) lives in a separate, private dotfiles repo,
managed with GNU Stow — installed
as a system package by 020-devtools.containerfile so it's always
available, without this repo ever needing to see what's inside it.
The pattern:
- Keep the dotfiles repo private on GitHub. It's the only thing
standing between your editor config and your API tokens, SSH config,
and shell history conventions. Clone it over SSH
(
git@github.com:<you>/dotfiles.git), not HTTPS — SSH auth to a private repo doesn't depend on a PAT sitting in a credential helper. - Lay it out as Stow packages. Each top-level directory mirrors
$HOMEfrom the point where it should be symlinked, e.g.fish/.config/fish/config.fishoremacs/.emacs.d/init.el. Runningstow fishfrom the repo root symlinksfish/.config/fishinto~/.config/fish;stow -D fishremoves the symlinks. This keeps the repo itself as the single source of truth —~/.config/fish/config.fishis a symlink, not a copy, so edits in place are edits to the repo. - Never put secrets in the repo, even though it's private. Private
repos still end up in local clones, backups, and (if a repo is ever
made public by mistake) full history. Reference secrets through
1Password's CLI instead:
set -x HF_TOKEN (op read "op://Personal/HF_TOKEN/credential")infish/config.fish, for example. The repo holds the lookup, never the value. 1Password (070-apps.containerfile) and its SSH agent integration are already part of this image for exactly this reason. - Gitignore anything that isn't config. Editors and agent tools
routinely drop caches, session logs, and backup files inside the same
directories your dotfiles live in (
.bakfiles,sessions/,*-cache.json,npm/). None of that belongs in version control — add it to the dotfiles repo's.gitignorebefore the firstgit add, not after something sensitive slips in. - Scan before you push.
gitleaks(added in020-devtools.containerfile, alongsidestow) scans a repo's working tree and full history for credential-shaped strings. Rungitleaks git .from the dotfiles repo periodically, or wire it in as a pre-commit hook (gitleaks protect --staged), so an accidentally hardcoded token gets caught locally instead of aftergit push.
Task runner for every workflow in this repo: assembling and building the
image (build, build-fresh), poking at it (shell, check-fonts,
check-packages), linting the repo's shell scripts (lint), running the
test suite (test, test-fast), swapping your live Framework laptop onto a
locally-built image (local-switch), managing the QEMU test VM (vm-*),
writing installer ISOs to USB (usb-list, usb-write), and cutting releases
(release, download-release).
Behavioral tests that run inside the built container image (via
vendor/shellspec, a vendored copy of the ShellSpec
BDD framework) to confirm the build actually produced a working system:
editors and CLI tools are on PATH and report sane --version output,
fonts are registered with fontconfig, Sway/waybar/swaylock are installed,
language runtimes are pinned to expected versions, llama.cpp's binaries and
bundled shared libraries are in place, and config files (default shell,
Homebrew/SDKMAN ownership) landed correctly.
build.yml— on every push/PR tomain(and weekly on a cron, to catch upstream Bluefin drift): assembles the Containerfile, verifies it's in sync, lints the repo's shell scripts with ShellCheck (just lint), builds the image, runs the full ShellSpec suite against it, and (main branch only) pushes toghcr.io/iwillig/dev-linux.build-disk-images.yml— on av*tag push (or manual dispatch): rebuilds and pushes the OCI image, then runsbootc-image-buildertwice to produce a bootableqcow2(for QEMU testing) and an Anacondaiso(for USB installs), compresses them, and attaches both to a GitHub Release.
Working directory for QEMU test-VM artifacts (disk images, downloaded ISOs,
OVMF firmware vars) created by the just vm-* recipes — gitignored, not part
of the build itself.
The recommended path uses a custom installer ISO built from your exact
OCI image via
bootc-image-builder. You
boot from it and the Anaconda installer puts your image directly onto
disk — no internet required on the target machine after that.
Go to github.com/iwillig/dev-linux → Packages → dev-linux → Package settings → Change visibility → Public.
This is required for bootc-image-builder and bootc to pull the image without credentials.
Tag a release to trigger the CI build:
just release v0.1.0CI will build the OCI image, run bootc-image-builder to produce a custom Anaconda ISO, and attach it to the GitHub Release. Once the run finishes (~15 min), download it:
just download-release # saves to vm/Or download manually from the Releases page.
# Find your USB device
just usb-list
# Write the ISO (replace /dev/disk4 with your USB device)
sudo dd if=vm/dev-linux-v0.1.0.iso of=/dev/disk4 bs=4m status=progressOn macOS use
just usb-write /dev/disk4— it handles unmounting and uses the raw device automatically.
- Plug USB into Framework, power on, press F12 for boot menu
- Select the USB drive
- Follow the Anaconda installer — partition as you like, set username/password
- Reboot — you're running your custom image
Whenever you add or change packages, push to main, wait for CI to finish, then on the Framework:
sudo bootc update
sudo rebootbootc update pulls only the changed layers from GHCR (fast after the
first pull), stages the new image alongside the running one, and
activates it on next boot. Your data in /home is untouched.
To check whether an update is available without applying it:
sudo bootc statusTo roll back to the previous image if something goes wrong:
sudo bootc rollback
sudo rebootIf you already have Fedora Silverblue installed, you can rebase to this image directly without a custom ISO:
sudo bootc switch ghcr.io/iwillig/dev-linux:latest
sudo rebootAfter the initial switch, future updates work the same way:
sudo bootc update
sudo rebootRequires podman and just. Works on both Linux (native) and macOS.
just build # build amd64 image locally via podman
just shell # open bash inside the built image
just test # smoke-test: verify key commands and fonts
just check-fonts # list installed fonts
just check-packages # list installed packagesSince you're already running dev-linux, the fastest feedback loop is to build locally and switch the running system to your changes:
just local-switch # build → export → sudo bootc switch (staged, not yet active)
sudo reboot # activate the new imageIf something breaks after rebooting:
sudo bootc rollback
sudo rebootbootc keeps the previous image around, so rollback is instant.
Requires podman (brew install podman) and just.
Test the image in an isolated VM before installing on bare metal. Downloads a stock Fedora Silverblue ISO, installs it into a QEMU disk, then you rebase to your custom image. On Linux the VM uses KVM hardware acceleration; on macOS it falls back to TCG software emulation.
just vm-download-iso # one-time: download Fedora 44 Silverblue ISO (~2.5 GB)
just vm-create # one-time: create 60 GB disk (+ init OVMF_VARS on Linux)
just vm-install # one-time: boot installer, follow Anaconda
just vm-run # start the VM
just vm-ssh # SSH into the running VM
# Inside the VM, switch to your image:
sudo bootc switch ghcr.io/iwillig/dev-linux:latest
sudo rebootTo test a locally-built image without pushing to GHCR:
just vm-snapshot # save a rollback point
just vm-load-local # push local image into VM via SSH
# Inside VM:
sudo bootc switch --transport containers-storage localhost/dev-linux:local
sudo rebootSkip the Silverblue install step entirely by downloading the pre-built qcow2 from a release:
just download-release # downloads and decompresses the qcow2 into vm/
just vm-run # boot straight into your imagejust release v0.2.0This tags the commit and pushes the tag. GitHub Actions then:
- Builds the OCI image and pushes it to
ghcr.io/iwillig/dev-linux:latest - Runs
bootc-image-builderto producedev-linux-v0.2.0.isoanddev-linux-v0.2.0.qcow2.zst - Attaches both to a GitHub Release
You can also trigger the disk image build manually from the Actions tab (useful for testing the ISO without tagging).
The laptop panel (eDP-1) auto-disables whenever the ViewSonic XG3220
external monitor is connected, so the external monitor is the only active
display rather than extending/mirroring — handled by kanshi
(/etc/kanshi/config), matched by monitor make/model/serial rather than
connector name so it keeps working regardless of which port/dock it's
plugged into.
- sway: automatic via kanshi. Manual override:
mod+shift+i(laptop only) /mod+shift+o(external only), orkanshictl switch laptop|externalfrom a terminal. - GNOME: kanshi requires the wlr-output-management protocol, which
mutter doesn't implement, so switching there is manual — use Settings →
Displays, or
gnome-monitor-config list/setfrom a terminal.
To support a different external monitor, update the output "..." match
string in /etc/kanshi/config — get the exact make/model/serial via
swaymsg -t get_outputs.
Sharing a screen or window in Zoom silently fails to start. The portal logs the error:
journalctl --user -u xdg-desktop-portal-wlr
[ERROR] - wlroots: No supported targets specified
This is an upstream bug in xdg-desktop-portal-wlr 0.8.1/0.8.2 (the version
shipped in Fedora 44):
emersion/xdg-desktop-portal-wlr#379.
SelectSources defaults its requested type-mask to 0 instead of MONITOR
when the caller omits the types option, so the type intersection is always
empty and the call fails — regardless of whether you pick a monitor or a
window in the share dialog. It is not specific to Zoom; any client that omits
types (per the portal spec, which allows this) hits it.
As of 2026-06-20, the latest upstream release (0.8.3) does not include a fix. A one-line patch is posted on the issue and has been community-confirmed to work, but isn't merged yet. Revisit once upstream merges a fix or Fedora picks up a patched build — until then, no workaround is applied in this image.