A bootc operating system based on Bluefin DX, running the niri scrolling Wayland compositor and the Noctalia shell.
This image is ghcr.io/ublue-os/bluefin-dx:stable with its own session. It
keeps everything Bluefin puts around the desktop, the ujust recipes, the
Homebrew and Flatpak plumbing, uupd's update policy, the codec and hardware
enablement, Bazaar and runs niri and Noctalia on top of it.
-dx is Bluefin's developer-experience variant, and this image carries its
container and toolchain half: Docker CE with the Compose and Buildx plugins,
the Podman extras (podman-compose, podman-tui), the tracing and debugging
tools (bpftrace, bcc, sysprof, gdb, strace), LLVM/Clang, ROCm,
kernel-devel, flatpak-builder, git-lfs and the devcontainer plumbing.
Before using Docker without sudo:
ujust configure-dev-groupsPodman and Docker are the whole of it, and the editor is Zed. Two build phases
say what left, which is worth reading before wondering where something went:
build/70-remove-virtualization.sh takes
out the host virtualization stack, and
build/75-remove-base-apps.sh the applications
this image replaces. The session itself is assembled in
build/60-niri-noctalia.sh.
The desktop, all from Fedora's own repositories:
- niri — the scrolling-tiling Wayland compositor, with xwayland-satellite
- noctalia — the shell: bar, dock, launcher, notifications, control centre, wallpaper, clipboard history, lock screen and settings UI, in one binary
- noctalia-greeter and greetd — the login screen, from the same project as the shell (see The greeter). The greeter is the one package not from Fedora: it comes from Terra, added for that one transaction and removed again
Applications and the utilities the session calls:
- ghostty — the terminal, from the
scottames/ghosttyCOPR, installed isolated so the repository is not left enabled in the image - nautilus — the file manager, inherited from Bluefin and kept explicitly
- adw-gtk3-theme — inherited the same way, and kept explicitly because nothing requires it: it is the GTK 3 theme Noctalia's templates apply
- gcr — inherited and kept explicitly for the same reason: it owns
gcr-ssh-agent.socket, the SSH agent that left withgnome-session - nm-connection-editor — inherited and kept for the same reason again.
Noctalia's network panel joins a network but cannot edit one: no forget, no
static address, no DNS. This is NetworkManager's own editor, and the VPN
types the
NetworkManager-*-gnomepackages supply are its plugins. Fedora ships it without a desktop entry, socustom/filesadds one and it appears in the launcher as Advanced Network Configuration - gnome-keyring, gnome-keyring-pam — Secret Service, unlocked at login
- upower, ddcutil — battery readings, and brightness on external monitors over DDC/CI
- grim, slurp, wl-clipboard, brightnessctl, playerctl — capture, region selection, clipboard, backlight and MPRIS control
- sound-theme-freedesktop — Noctalia's event sounds
The build sets install_weak_deps=0, so nothing arrives by Recommends:
every one of these is named on purpose.
Declared in custom/flatpaks/ and
installed by flatpak preinstall the first time the system boots with a
network connection:
- Zen (
app.zen_browser.zen) — the browser, and the default handler forhttp,httpsand HTML (seecustom/config/mimeapps.list) - Zed (
dev.zed.Zed) — the editor, from the build its own project publishes on Flathub - Thunderbird (
org.mozilla.thunderbird) — mail, calendar and newsgroups, and the default handler formailto: - Flatseal, Extension Manager
~/.config/mimeapps.list— seeded fromcustom/config/into every new account, making Zen the default browser and Thunderbird the default mail and calendar client. It is the highest-precedence file in the XDG association chain, so it wins over the base image's defaults. An account that already exists keeps its own copy;ujust install-configapplies this one, backing up what it replaces~/.config/noctalia/10-theme-templates.toml— seeded the same way fromcustom/config/, turning on the Noctalia templates that carry the session's colour scheme into GTK applications. Its own comments cover the rest~/.config/noctalia/20-polkit-agent.toml— seeded the same way fromcustom/config/, turning on Noctalia's authentication agent, which is what draws the password prompt for a privileged action. Its own comments cover the rest/etc/niri/config.kdl— the image's niri configuration, shipped throughcustom/files/and validated byniri validateduring the build, so a broken config fails CI rather than your first login/etc/greetd/config.toml— greetd runsnoctalia-greeter-sessionas greetd's own service account/etc/pam.d/greetd— a requiredpam_systemd.sosession line, so the greeter gets the logind session it needs to reach the seatnoctalia-greeter-setup.service— creates/var/lib/noctalia-greeter, because90-cleanup.shprunes/varout of the image, and syncs the greeter's keyboard layout fromlocaledon every bootdisplay-manager.servicenow points atgreetdos-releasecarriesVARIANT="Niri"/VARIANT_ID=niri
No portal configuration is shipped: niri's own
/usr/share/xdg-desktop-portal/niri-portals.conf already names the backends it
wants — xdg-desktop-portal-gnome for screencasting, since niri speaks the
same ScreenCast D-Bus API, xdg-desktop-portal-gtk for the file chooser and
notifications, and gnome-keyring for secrets. The build asserts those three
are installed.
Noctalia supplies the bar, launcher, notifications, lock screen, wallpaper and
settings; niri supplies the window management. The binds below are the ones
that differ from stock niri. Mod is Super.
| Bind | Does |
|---|---|
| Mod+T | Ghostty |
| Mod+E | Files |
| Mod+D / Mod+Space | Noctalia launcher |
| Mod+S | Noctalia control centre |
| Mod+N | Notification history |
| Mod+, | Noctalia settings |
| Mod+Alt+L | Lock the screen |
| Mod+Alt+V | Clipboard history |
| Mod+Shift+Print | Noctalia screenshot, with annotation |
| Mod+Shift+/ | niri's hotkey overlay — everything else |
Volume, microphone, media and brightness keys route through Noctalia so its on-screen display shows the change. Everything not listed is stock niri; press Mod+Shift+/ for the full list.
To change any of it:
ujust niri-edit-configThat copies /etc/niri/config.kdl into ~/.config/niri/config.kdl, opens your
$EDITOR, and validates the result. niri reloads a valid config as soon as it
is written and keeps the last good one when it is not. Edit the copy, never
/etc: /etc is three-way merged on every image update.
niri has no bundled greeter of its own, so the image picks one.
It uses Noctalia Greeter — the login screen built by the same project as the shell, so the greeter and the session share a visual language rather than merely coexisting.
It is a greetd greeter, so greetd is the display manager: greetd runs
noctalia-greeter-session, which starts the greeter's own bundled wlroots
compositor and draws the greeter inside it. It reads
/usr/share/wayland-sessions, so niri appears in its session picker with no
extra wiring.
The greeter reads /var/lib/noctalia-greeter/greeter.toml, whose own header
documents every key it takes — [appearance] (colour scheme, password style,
corner radius, font), [appearance.palette] for the full colour-role table,
[appearance.wallpaper], plus [output], [keyboard], [idle] and [cursor].
The easier path is to let the shell drive it:
noctalia msg greeter-syncThat pushes the session's current wallpaper and colour palette to the greeter, so the login screen matches whatever theme you are running. It goes through the Polkit action the greeter package installs, so it prompts once rather than needing a root shell.
noctalia-greeter-setup.service creates that state directory and its default
greeter.toml the first time the machine boots. It has to happen there rather
than at build time: build/90-cleanup.sh prunes /var, so nothing written
under it during the build survives into the shipped image.
The same unit fills in [keyboard], and that one is not cosmetic.
The greeter's compositor builds its keymap with xkb_keymap_new_from_names and
reads no locale1 state of its own, so a greeter.toml without a [keyboard]
section — which is exactly what the vendor's --setup-system writes — leaves
libxkbcommon on its built-in default of us. On any other layout the password
is then typed through the wrong keymap and greetd rejects it as a PAM
AUTH_ERR, which is indistinguishable from getting the password wrong.
niri behaves the opposite way, which is why
/etc/niri/config.kdl leaves its xkb
block empty: the session follows the machine unaided, the login screen does not.
/usr/libexec/noctiri-greeter-setup.sh
closes the gap. It reads XKBLAYOUT, XKBVARIANT and XKBOPTIONS from
/etc/vconsole.conf, falls back to Xorg's 00-keyboard.conf, and rewrites just
those three keys — so numlock and anything else you put in [keyboard]
survives. localectl stays the single place to change it:
sudo localectl set-x11-keymap ch pc105 fr
sudo systemctl restart noctalia-greeter-setup.serviceA machine that configures no X11 keymap is left alone rather than pinned to a layout it never asked for.
noctalia-greeter is the only package in this image that does not come from
Fedora — it is not packaged there, and upstream's installation guide points
Fedora users at Terra. The build adds Terra for that one
transaction and removes the repository file again, with two restrictions worth
knowing about:
includepkgs=noctalia-greeter. Terra also carriesnoctalia,noctalia-nightly,noctalia-qsandnoctalia-legacy, and itsnoctaliais older than the 5.1.0 this image takes from Fedora. Without the restriction, Terra would be free to downgrade the shell.- The signing key is pinned by fingerprint. Terra publishes no
.repofile to inherit, and signs each Fedora release with a different key — so the pin is per Fedora major, and a Fedora rebase fails the build until someone verifies the new key and adds it toTERRA_KEY_FINGERPRINTSinbuild/60-niri-noctalia.sh. A rotated trust root should be a decision, not a silent fetch.
Build system
- A build on every push to
main, publishing:stable-testing - Renovate through
projectbluefin/actions, updating pinned actions and image digests every six hours - Images older than 90 days pruned automatically
- Pull requests validated for shellcheck, hadolint, Brewfiles, Flatpaks, Justfiles, and Renovate config
- Keyless OIDC signing on every published image, enforced at promotion (where the signature is checked)
Runtime
- Homebrew, pre-staged at build time and unpacked on first boot
- Flatpaks declared in
custom/flatpaks/, installed on first boot ujustshortcuts for the Brewfiles and for re-applying configurationuupdfor scheduled system updates
Pick your base image on the Containerfile's FROM line; this image uses
ghcr.io/ublue-os/bluefin-dx:stable. That line is the only place the base is
chosen: just build reads the image name and the tag from it, and the Fedora
major comes from the base image itself during the build. Move to another base
and the desktop phase and the two removal phases are where it shows: each
verifies what it did, so a mismatch fails the build rather than shipping a
broken image.
Then add to your image:
- System packages —
build/20-packages-and-services.sh(guide) - The desktop —
build/60-niri-noctalia.shandcustom/files/etc/niri/config.kdl - CLI tools —
custom/brew/(guide) - GUI apps —
custom/flatpaks/(guide) - Commands —
custom/ujust/(guide)
The customize skill decides which of
those a given package belongs in.
| Branch | Image tag | Audience |
|---|---|---|
main |
:stable-testing |
Testers and release candidates |
stable |
:stable |
Production |
Merging to main publishes :stable-testing; the promotion PR that follows
publishes :stable when merged. Promotion verifies the cosign signature on the
testing image before it reports ready, and refuses to promote at all once main
has moved past the commit the promotion PR was built from.
Known gap: the promotion gate checks the digest and the signature only. It runs no end-to-end tests, so
release/readymeans "signed and unmodified", not "functionally validated".
Images are signed with keyless OIDC via Cosign and GitHub Actions. There is no key to generate or store.
cosign verify \
--certificate-identity-regexp="https://github.com/vincent-pochet/noctiri/.github/workflows/" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/vincent-pochet/noctiri:stableUnsigned images fail the promotion gate, so main → stable reports
release/blocked until signing is restored.
In CI, on the way to :stable — and not verified on the device. The
promotion gate is the only enforcement point. An installed system pulls its
updates over an unverified transport (image-info.json's image-ref is
ostree-unverified-image:docker://…), so bootc upgrade does not check the
cosign signature.
That is a deliberate statement of what the image can actually do, not an
oversight. Device-side verification runs through
/etc/containers/policy.json, which matches a keyless Fulcio certificate on
subjectEmail only — mandatory and exact. A GitHub Actions certificate
identifies its workflow in a URI SAN and carries no email, so no policy entry
can match it, and the inherited policy's "" catch-all
(insecureAcceptAnything) would accept the image regardless. A signed-looking
image-ref here would verify nothing while implying it verified something.
Making updates verify on the device means signing with a key the policy can
name: publish with a cosign keypair, merge a sigstoreSigned scope for your
namespace into the inherited policy (with jq, during
build/10-overlay.sh — never by shipping a whole
policy.json through custom/files/, which freezes every scope you inherited),
add a registries.d entry with use-sigstore-attachments: true for it, and
flip IMAGE_REF back to ostree-image-signed:. Validate that on a real
install before shipping it: a scope that does not match turns bootc upgrade
into a hard refusal. tests/contract/image-signing_test.bats holds the two
sides together, so changing one without the other fails the suite.
Switch to a built image:
sudo bootc switch --transport registry ghcr.io/vincent-pochet/noctiri:stable-testing
sudo systemctl rebootThen, as your user:
ujust install-default-apps # Homebrew: the default Brewfile
ujust install-dev-tools # Homebrew: the development Brewfile
ujust configure-dev-groups # add yourself to the docker group
ujust install-config # re-apply the image defaults, backing up yours
ujust niri-edit-config # copy the niri config into your home and edit itFirst boot unpacks Homebrew and installs the declared Flatpaks; both need a
network connection. Check them with systemctl status brew-setup.service and
systemctl status flatpak-preinstall.service.
just build # build the container image
just build-qcow2 # build a QCOW2 disk image
just run-vm-qcow2 # boot it in a browser-based VM
just build-iso # build an installer ISO
just test-unit # run the test suiteThe troubleshooting skill covers
build, CI, and runtime failures symptom-first. The two most common first-boot
surprises:
- No Flatpaks.
flatpak-preinstall.serviceneeds a network connection and reports success even when it cannot reach Flathub, so a first boot before Wi-Fi is configured installs nothing. Reboot once you are online. - No
brew.brew-setup.serviceunpacks Homebrew on first boot; check its status before reaching for a reinstall. - An unfamiliar login screen. That is Noctalia Greeter: see
The greeter. Log in and niri starts; if it does not,
journalctl -b -u greetdandjournalctl --user -u nirihave the reason. - The greeter rejects a password you know is right. Check the layout before
the account:
grep -A4 '^\[keyboard\]' /var/lib/noctalia-greeter/greeter.tomlagainstlocalectl status. A greeter with no[keyboard]section types on a US keymap;systemctl status noctalia-greeter-setup.servicesays why it did not get one. A text login onCtrl+Alt+F3uses the console keymap instead, so it working there confirms the layout rather than the password. - A greeter that does not match your desktop theme. Run
noctalia msg greeter-syncfrom the session. If the state directory is missing entirely,systemctl status noctalia-greeter-setup.servicesays why it did not run. git pushover SSH fails withPermission denied (publickey). Check the agent before the key:ssh-add -lansweringError connecting to agentmeansgcr-ssh-agent.socketis not running. The image enables it for every user, so asystemctl --user is-enabled gcr-ssh-agent.socketthat saysdisabledis an account that turned it off;systemctl --user enable --now gcr-ssh-agent.socketputs it back. A passphrased key is cached in the login keyring, whichpam_gnome_keyringunlocks at login- A bar-less, wallpaper-less niri. Noctalia is started by niri through
spawn-at-startupin/etc/niri/config.kdl. A~/.config/niri/config.kdlcopied from an older image, or from upstream niri, will not have that line.
See CONTRIBUTING.md.