Skip to content

About

Personal Bazzite bootc desktop image (KDE + NVIDIA) hardware-tuned for a Meteor Lake ThinkPad: VFIO/QEMU/libvirt + Docker CE, Cockpit web management, dev tooling, audio/power/storage fixes, and a CI smoke/boot-test gate that blocks broken images.

Topics

Resources

Contributing

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

bazzite-tower

Build Boot test License Image

bootc Fedora KDE Plasma NVIDIA QEMU/KVM cosign

A custom bootc image derived from ghcr.io/ublue-os/bazzite-nvidia-open, tailored for an NVIDIA RTX-equipped desktop/laptop workstation that doubles as a virtualization host and developer machine. Built weekly, signed with cosign, published to ghcr.io/bearyjd/bazzite-tower.

Why this exists

Stock Bazzite KDE is excellent for gaming, but every install needs the same post-boot setup: enable libvirt sockets, run ujust setup-virtualization (which is broken on the modular libvirt that ships in F44+), add yourself to libvirt and kvm groups, install Docker on top of Podman, drag in dev tooling. bazzite-tower bakes all of that into the image so the first boot is the only boot you need.

This is a desktop/laptop variant — not for handhelds or Steam Deck. It uses NVIDIA's open kernel modules rather than the proprietary driver (see Design choices for why, and the trade-offs that decision carries). If you'd rather run the proprietary driver — required for pre-Turing cards (Maxwell/Pascal/Volta) — rebase the FROM to bazzite-nvidia:stable or a pinned bazzite-nvidia tag.

What's included beyond stock Bazzite

Virtualization stack (qemu:///system works on first boot)

  • qemu-kvm, libvirt, libvirt-daemon-kvm, libvirt-client
  • libvirt-daemon-config-network, libvirt-daemon-config-nwfilter (default NAT network + nwfilter rules)
  • virt-manager, virt-install, virt-viewer
  • edk2-ovmf (UEFI firmware for VMs)
  • guestfs-tools, spice-gtk3

Developer tooling

  • android-tools — adb/fastboot for device flashing
  • flatpak-builder — build local Flatpaks
  • restic, rclone — backup and cloud sync
  • zsh — alternative shell
  • ccache — compile caching for native builds
  • podman-machine, podman-tui — Podman VM and TUI

Docker CE alongside Podman

Docker CE is installed from the upstream download.docker.com repo (not Fedora's moby-engine):

  • docker-ce, docker-ce-cli, containerd.io
  • docker-buildx-plugin, docker-compose-plugin

The Docker repo file ships with every section disabled. Packages are pulled in via --enablerepo=docker-ce-stable only during the image-build transaction, so the repo never participates in runtime updates.

Docker is installed but its daemon/socket and group membership are off by default. Opt in per host with ujust enable-docker; it loads iptable_nat only for that host and warns that the docker group is root-equivalent.

When Docker starts, its DOCKER-USER chain receives narrowly-scoped rules for each active libvirt network configured as NAT. The helper derives that bridge's live IPv4 network and the current default-route interface, then allows only NEW,ESTABLISHED,RELATED guest-originated traffic toward that uplink plus RELATED,ESTABLISHED reply traffic. It neither broadens source-RFC1918 or bridge-wildcard access nor changes Docker's FORWARD policy, Docker-managed chains, or nftables tables. If Docker did not create DOCKER-USER, the post-start helper reports the failure but does not prevent Docker from starting; later libvirt and NetworkManager events retry reconciliation. Every generated pair has a deterministic helper-owned comment. When a NAT network, bridge address, default route, VPN state, or NetworkManager connection configuration changes, reconciliation removes only stale rules with that comment prefix; it never flushes or changes unowned DOCKER-USER rules. Changes in libvirt's network state directory are detected by the systemd path unit docker-libvirt-forwarding.path (watching /run/libvirt/network), which runs docker-libvirt-forwarding.service only while both docker.service and virtnetworkd.service are active; it runs the helper twice about 2 s apart, and docker-libvirt-forwarding.timer re-runs it about every 10 minutes as a self-heal. The helper refuses a default-route interface whose name is not plain [A-Za-z0-9_.-]{1,15}. It is a path unit rather than a libvirt network hook because libvirt's SELinux-confined virtnetworkd_t cannot exec hooks, and a failing hook aborts network start. NetworkManager route/VPN/reapply events request reconciliation asynchronously via a dispatcher script, without blocking the event.

Run the privileged host check after opting in with just test-docker-libvirt-forwarding; it restarts Docker, verifies those exact rules, and runs a Docker bridge-network probe. It is deliberately not a CI requirement because nested Docker/firewall support is runner-dependent.

OpenSnitch (application firewall)

OpenSnitch is baked in daemon-only (no GUI package): opensnitch.service is enabled at boot. It is the interception engine for Snitchwatch, which replaces upstream's opensnitch-ui — do not install opensnitch-ui, the two conflict and Snitchwatch has an explicit coexistence check.

The image ships a Snitchwatch-tuned /etc/opensnitchd/default-config.json with three deliberate changes from the RPM's shipped default:

Key Value Why
Server.Address 127.0.0.1:50051 The Snitchwatch bridge's gRPC listener, instead of opensnitch-ui's unix:///tmp/osui.sock.
ProcMonitorMethod proc Tested baseline. A historical module-load error (snitchwatch#6) does not establish kernel incompatibility. All three RPM eBPF objects are checked at build time; owner-only runtime A/B testing is still required.
DefaultAction allow (both profiles) Permanent default; deny enforcement always requires explicit operator opt-in.

Both profiles permanently ship DefaultAction: allow: unanswered unruled connections use allow, so headless networking remains available. Deny enforcement is always an explicit operator opt-in, never a rollout end state. A local DefaultAction: deny can block every new unruled outbound connection when no decision client answers prompts. DropWhileAsking is not enabled by the image; explicitly opting into it can drop traffic while prompts are pending even with the allow default. Explicit firewall rules still apply.

The opt-in image candidate selects SNITCHWATCH_BRIDGE=system with FIREWALL_DAEMON=opensnitch; the default remains legacy. The candidate installs the pinned native bridge version 0.1.1, system socket/service units and immutable provenance. The r15.1 bridge/GUI source pin is 5c969a2e21dc7f5c976e7048aa9f95e5663c7de2; its native bridge, wire protocol, packaging inputs and Flatpak permissions retain the r15 bytes. It also builds OpenSnitch 1.8.0 from the reviewed upstream pin plus the downstream queue-shutdown and NFT policy-ownership repair. A separate daemon manifest verifies the installed executable, repair patch, build inputs and licenses. OpenSnitch uses unix:opensnitchd.sock from /run/snitchwatch (upstream's opensnitch.service drop-in, WorkingDirectory=-/run/snitchwatch) with DefaultAction: allow (deny requires explicit operator opt-in) and ProcMonitorMethod: proc (upstream's config says ebpf; the tower keeps the tested proc baseline). If the socket fails the daemon still starts and unanswered unruled connections use the allow default. System readiness and migration require the shipped allow policy; an explicit local deny override is reported as policy drift. See the runbook. GUI installation and GUI group enrollment remain explicit operator steps.

Build locally with just build-snitchwatch-system. For a disposable SSH test image, use just build-snitchwatch-system-vm, then just run-vm-ssh localhost/bazzite-tower snitchwatch-system-vm <unused-port>; VM_GATE_SSH=1 belongs only to this test image. Pull-request CI builds a local verify-snitchwatch-system candidate with read-only publishing permissions. Releases publish it as the opt-in :snitchwatch-system tag (see Tags); :latest and :latest-kernel keep the legacy bridge. The GUI is a separate per-user Flatpak and is not part of that image.

October 4–5 disposable-VM results cover the reviewed bridge/GUI artifacts, including conditional rendered-GUI decisions and bounded request cleanup. Fresh native bridge reproducibility and the clean GUI release build from 5c2b44a passed. The GUI uses KDE 6.11 / Qt 6.11.2 and Rust 1.98.1; its crate version remains 0.1.0. The accompanying archive supplies full project and dependency sources and license texts. Candidate image/first-boot, default KDE GUI decisions and repaired daemon shutdown/NFT behavior under enforcing SELinux still require fresh VM evidence. See the candidate migration runbook and system bridge evidence.

Flipping the legacy image to deny is order-sensitive. The config change and the bridge install are two separate manual steps, and doing them in the wrong order costs you the network:

  1. Install and enable the released snitchwatch-bridge.service, then run ujust opensnitch-readiness. It is read-only and succeeds only when OpenSnitch is active, remains configured DefaultAction: allow, and the enabled active user service's MainPID resolves to the released bridge binary and owns 127.0.0.1:50051. This rejects a stale debug bridge merely occupying the port. The release-install contract is intentionally fixed: install the executable at ~/.local/bin/snitchwatch-bridge-cli and a verified one-line release manifest at ~/.local/share/snitchwatch/bridge.sha256, formatted as <sha256> snitchwatch-bridge-cli. The preflight hashes the executable and refuses a missing, malformed, or mismatched manifest. Do not substitute an arbitrary path or manifest through environment variables.
  2. While still fail-open, trigger and answer a controlled Snitchwatch prompt in the active graphical session. The readiness command cannot prove the decision client will answer prompts; a listening gRPC socket alone is insufficient.
  3. Only after both checks succeed should an operator consider deny. The image still ships DefaultAction: allow in both profiles and always will; deny is an operator opt-in available only on the system profile (the legacy TCP profile is refused: its loopback port can be squatted, issue #35). Use the guarded trial: ujust snitchwatch-deny on [minutes] (1-60, default 10) switches only DefaultAction to deny, restarts OpenSnitch and arms an automatic revert; answer a prompt in Snitchwatch and run ujust snitchwatch-deny confirm to keep it, otherwise it reverts at the deadline or at the next boot (before the firewall starts). ujust snitchwatch-deny off returns to allow; ujust snitchwatch-deny status shows state (sudo /usr/libexec/bazzite-tower-snitchwatch-deny status --json for a stable machine-readable form). Readiness accepts a confirmed deny as deny (operator opt-in); a pending or markerless deny still fails as drift.

The helper also publishes a read-only status snapshot at /run/snitchwatch/deny-status.json for the GUI (fields, staleness rules: docs/RUNBOOK.md).

If a deny trial leaves a host unable to connect, wait for the automatic revert or reboot (the boot guard reverts an unconfirmed trial). Otherwise recover from a TTY with sudo /usr/libexec/bazzite-tower-snitchwatch-deny off, or by setting DefaultAction back to allow in /etc/opensnitchd/default-config.json and running systemctl restart opensnitch.service, or use rpm-ostree rollback.

Existing r15 hosts with a locally edited deny config may retain it across the r15.1 upgrade through ostree's /etc merge. Explicitly adopt allow and restart OpenSnitch to use the shipped policy; see the r15.1 transition.

The pristine image-intent copy lives at /usr/share/bazzite-tower/opensnitchd-default-config.json. Because /etc is 3-way merged across bootc upgrades, a locally edited /etc/opensnitchd/default-config.json stops tracking the image — diff the two to see exactly what drifted.

Runtime dependencies are installed explicitly (libnetfilter_queue, nftables) because the extraction below resolves none: libnetfilter_queue.so.1 is a hard DT_NEEDED of opensnitchd and is absent from the pinned base, so without it the daemon cannot exec at all.

Not available in Fedora or RPM Fusion (the one Fedora-44 COPR has zero builds), so it's extracted directly from the pinned upstream release RPM (v1.8.0, verified by a hardcoded sha256 before extraction) rather than dnf/rpm installed — see Disabled-by-default external repos for why this repo prefers pin-and-verify over trusting an unverifiable third-party GPG key. It's therefore not registered in the rpm database (rpm -q opensnitch won't find it); check for /usr/bin/opensnitchd instead.

VM management recipes (ujust)

bazzite-tower ships extra ujust recipes (in /usr/share/ublue-os/just/60-custom.just) for driving the modular libvirt stack:

Recipe What it does
ujust vm-start Start the modular libvirt sockets (virtqemud, virtnetworkd, virtstoraged, virtnodedevd)
ujust vm-stop Stop those sockets
ujust vm-list virsh -c qemu:///system list --all
ujust vm-net-status virsh -c qemu:///system net-list --all
ujust fix-vm-groups Add the current user to kvm, libvirt (then log out/in)
ujust wifi-debug Dump Wi-Fi diagnostics (rfkill, lspci, iwlwifi/DMAR dmesg, modules, NetworkManager, firmware, kernel cmdline) — read-only, works offline
ujust tower-health Read-only summary of optional services, monitoring, firmware, and security-tool availability
ujust opensnitch-readiness Read-only fail-open preflight: OpenSnitch active plus the released user bridge service owning 127.0.0.1:50051; it never enables enforcement
ujust snitchwatch-deny [status|on|confirm|off] [minutes] System profile only: opt-in, auto-reverting OpenSnitch DefaultAction: deny trial; the image keeps shipping allow. Refuses the legacy profile; a reboot or timeout before confirm reverts it
ujust enable-docker Opt in to Docker socket activation and the root-equivalent Docker group (then log out/in)
ujust enable-cockpit Opt in to Cockpit bound only to loopback; prints the reviewed Tailscale Serve command to run separately
ujust enable-waydroid Opt in to the Waydroid service; Android initialisation remains separate

The stack is socket-activated and enabled at boot, so vm-start is rarely needed — it's there for when you've manually stopped the daemons.

Wi-Fi not detected?

If Wi-Fi looks dead after a boot — no networks, NetworkManager shows no usable Wi-Fi device — run ujust wifi-debug (it works without a network) and read it top-down. Check the easy, common causes before assuming a driver/firmware problem:

  • nmcli shows the device as unavailable (not disconnected), but the driver-level scan in wifi-debug finds networks → the radio is fine; NetworkManager's wifi backend is pointing at a supplicant that isn't running. The classic case: /etc/NetworkManager/conf.d/iwd.conf sets wifi.backend=iwd while iwd is inactive (wpa_supplicant is what's actually running). This frequently happens after a bootc rebase, because /etc persists: a wifi.backend=iwd file from a previous image survives, but the enabled iwd service does not. bazzite-tower ships a guard for exactly this (see Wi-Fi backend guard), so on a fresh boot it self-corrects; to fix a running session immediately, revert to the default backend with sudo mv /etc/NetworkManager/conf.d/iwd.conf ~/iwd.conf.disabled && sudo systemctl restart NetworkManager (or sudo systemctl enable --now iwd if you actually want iwd).
  • rfkill shows a hard block → a physical/Fn switch or a BIOS setting, not the image.
  • lspci doesn't list the wireless card at all → disabled in BIOS or a hardware/seating issue.
  • lspci lists the card but dmesg shows iwlwifi ... DMAR faults or Failed to start ... ucode → the IOMMU is knocking out iwlwifi. An Intel CNVi card can fail to initialize under intel_iommu=on, and the wireless device then never registers. Confirm by rebooting, pressing e in GRUB, removing intel_iommu=on iommu=pt from the linux line, and booting once. If Wi-Fi returns, the IOMMU karg (/usr/lib/bootc/kargs.d/00-iommu.toml, added for PCI passthrough) is the cause — drop that fragment if you don't need VFIO passthrough, and rebuild.
  • Intel BE200 (Wi-Fi 7) specifically: newer kernels (6.15+) drive it with the new iwlmld op-mode and require firmware ≥ v100 — there is no usable iwlmvm fallback, so don't bother downgrading firmware. If wifi-debug's driver-level scan works, the BE200 itself is fine and the problem is upstream of the driver (almost always the backend issue above).

Design choices

NVIDIA open kernel modules over proprietary

Until 2026-08-28 this image ran the proprietary driver instead, on the reasoning that hybrid (Optimus) laptops were where the open modules lagged — NVIDIA's docs flagged power management as known-incomplete, and hybrid-laptop suspend/hibernate bug reports against open-gpu-kernel-modules remained open into 2026. That reasoning didn't get overturned by new evidence about this specific hardware (Meteor Lake + Ada RTX 4070 Max-Q, ThinkPad P1) — no suspend/resume report specific to this platform surfaced in either direction for either driver family. What changed was upstream's kernel packaging: the i915 Meteor Lake s2idle-resume fix landed in kernel 7.2, but bazzite-nvidia (proprietary) then forked onto its own ogc-lts kernel track (6.18.x) that will not follow 7.2 forward. bazzite-nvidia-open is the only upstream-tracked tag that carries both the fix and ongoing updates. Full history: docs/research/i915-bug-report/UPSTREAM-FIX-STATUS-2026-08-28.md.

This is a deliberate trade, not a strict upgrade, on two fronts:

  • Driver branch. bazzite-nvidia-open tracks NVIDIA's New Feature Branch (currently 610.x, an 11-month support window, weeks old at the time of this switch). The proprietary flavor stayed on the Long Term Support Branch (580.x, 3 years of support, bugfix/security-only by policy — it will never gain the newer Vulkan extensions, DRM color-pipeline/HDR output, or zero-copy NVDEC import that 610 added). Faster features, less field-proven.
  • GPU-switching stack. The open flavor uses cardwire (eBPF-based, describes itself as "early development"); the proprietary/LTS flavor uses the older supergfxctl, which has at least one reproduced failure mode on this same Bazzite stack (ublue-os/hwe#200, dGPU-only mode leaving the internal display black). Neither tool has a clean record.

bootc rollback, or re-pinning BASE_IMAGE back to a bazzite-nvidia tag, is the fallback if this doesn't hold up in practice. On an RTX 40-series (Ada) card the open modules are fully supported (Turing+); the proprietary driver remains one FROM-line swap away (bazzite-nvidia:stable, or a pinned tag) if you'd rather trade features back for the LTSB's longer track record — or if your card predates Turing, where the open modules aren't an option at all.

Wi-Fi backend guard

NetworkManager picks a Wi-Fi backend (wpa_supplicant by default, or iwd). Because /etc persists across a bootc rebase, a wifi.backend=iwd config from a previous image can outlive its enabled iwd service — NetworkManager then points at a supplicant that never runs, and every Wi-Fi device reports unavailable (which looks exactly like a missing card, even though the radio is fine).

bazzite-tower-wifi-backend-guard.service runs before NetworkManager on each boot. If any config selects wifi.backend=iwd while iwd is not enabled, it drops a late-sorting override (/etc/NetworkManager/conf.d/zzz-bazzite-tower-wifi-backend-guard.conf) restoring the default wpa_supplicant backend — and removes that override automatically the moment iwd is properly enabled, so a deliberate sudo systemctl enable --now iwd is always respected. The backend is corrected before NM starts, so no restart is needed.

Modular libvirt (no manual ujust setup-virtualization)

Fedora 44+ defaults to modular libvirt: per-driver daemons (virtqemud, virtnetworkd, virtnodedevd, virtnwfilterd, virtstoraged) replace the monolithic libvirtd. bazzite-tower enables those modular sockets at build time (enabling each primary .socket also pulls in its -ro/-admin variants via the unit's Also= directive). The legacy libvirtd.service is masked so it can't race the modular daemons — that race is the root cause of broken ujust setup-virtualization on stock images.

A container dnf install doesn't run systemd-sysusers the way an rpm-ostree compose does, so the qemu system user that the libvirt packages declare via sysusers.d is never created — and virtqemud then aborts at startup (Failed to parse user 'qemu') and crash-loops, so qemu:///system would silently never come up. build.sh materializes that user at build time: it strips the orphan qemu: shadow/gshadow lines the base image ships (which otherwise make systemd-sysusers roll the whole transaction back and create nothing), runs systemd-sysusers, then falls back to a guarded groupadd/useradd. The runtime boot test connects to qemu:///system on every change to keep this honest.

For tooling that still expects the monolithic /run/libvirt/libvirt-sock, virtproxyd.socket is enabled. virtproxyd is the modular drop-in for that legacy path: it forwards to the per-driver daemons. It and libvirtd.socket both declare Conflicts= on the same socket path, so only virtproxyd.socket is enabled (libvirtd.socket would be inert anyway with its service masked).

The default NAT network (shipped by libvirt-daemon-config-network) is marked autostart at build time by creating the autostart/default.xml symlink that virsh net-autostart would — so guests get networking on first boot without manual setup.

IOMMU enabled for PCI passthrough

intel_iommu=on iommu=pt are baked in as kernel arguments via a bootc kargs.d fragment (/usr/lib/bootc/kargs.d/00-iommu.toml), enabling VFIO/PCI passthrough to guests. This uses bootc's native karg mechanism rather than rpm-ostree kargs, which can't run during an image build. Target hardware is Intel (ThinkPad P1); iommu=pt keeps DMA-remapping overhead off host-only devices.

Intel display & suspend stability

The target panel (Intel iGPU on Meteor Lake) throws eDP link/PLL errors with flicker and post-resume corruption when the i915 driver's panel power-saving is left on. Separately, a kernel 7.0 regression corrupted the i915 PHY A / C10 (cx0) PLL state on s2idle resume (~30s of flip-done timeouts and a sluggish display after wake); the upstream fix (062499cc4813b5a3, closes freedesktop #16042) landed in kernel 7.2, which is what this image now pins to (see Design choices for why that specifically required moving off the proprietary driver). Two more bootc kargs.d fragments address the panel-power-saving side, which the kernel fix doesn't touch:

  • 10-i915-display.toml — i915.enable_dc=0 i915.enable_psr=0 i915.enable_psr2_sel_fetch=0 disable Display C-states and Panel Self Refresh (the three are one intervention). Cost is marginally higher panel power; the trade is a stable display.
  • 20-suspend.toml — mem_sleep_default=s2idle pins s2idle suspend. Meteor Lake has no working S3 ("deep") suspend; an earlier attempt to default to deep made resume worse (bounce behaviour), so we pin s2idle explicitly rather than relying on the firmware fallback. Check the live mode with cat /sys/power/mem_sleep (the bracketed entry is active). scripts/check-i915-resume-fix.sh (weekly user timer) keeps watching the journal for the PHY A regression signature — insurance against a future regression, now that this pin carries the fix.

Each is its own fragment, so you can drop either independently if your hardware is happy without it. Like the IOMMU karg, these use bootc's native mechanism rather than rpm-ostree kargs (which only sets per-machine local state and can't run during an image build).

Two-layered libvirt/kvm access for the default user

Bootc images don't bake in a default user — the first user is created by KDE Plasma's initial-setup on first boot. bazzite-tower uses two complementary mechanisms to give that user immediate virtualization access:

  1. Polkit rule (/etc/polkit-1/rules.d/50-libvirt-wheel.rules) — grants unix-group:wheel access to org.libvirt.unix.manage and org.libvirt.unix.monitor. Anyone in wheel can talk to qemu:///system from virt-manager and virsh immediately, no logout required.
  2. First-boot oneshot (bazzite-tower-firstboot.service) — runs after systemd-user-sessions.service, finds the first UID≥1000 user, and runs usermod -aG kvm,libvirt (adding only groups that exist). This grants real group membership for tools that check groups and for raw /dev/kvm access. The unit retries every boot until a regular user exists, then writes a marker file (/var/lib/.bazzite-tower-groups-done) so it stops running.

Result: virsh -c qemu:///system list and virt-manager work on first login (via the polkit rule). Raw /dev/kvm (qemu-system-x86_64 -enable-kvm) works once the first-boot service has applied the groups — in practice after the next reboot following initial account creation, plus a fresh login session to pick the new groups up.

Docker CE instead of podman-docker

podman-docker (the package that aliases docker to podman) is removed at build time. Docker CE is installed alongside Podman. Both daemons can coexist — different binaries, different sockets, different state — pick whichever your workflow expects without alias trickery.

Docker remains inactive until ujust enable-docker is run on that host. The recipe adds the current user to the Docker group only after warning that this is root-equivalent; log out and back in before using it without sudo.

Optional host services

Docker, Cockpit, and Waydroid are installed for availability but disabled by default. ujust enable-docker, ujust enable-cockpit, and ujust enable-waydroid are the only image-provided activation paths. Cockpit's socket is loopback-only even after activation; review Tailnet ACLs and run the printed tailscale serve --https=443 http://127.0.0.1:9090 command yourself if you choose to publish it. The recipe never configures Serve, auth keys, or firewall rules. Waydroid's Android image initialisation is likewise separate.

tailscaled.service is enabled only to make the client available; it has no baked node identity, authentication, Serve/Funnel configuration, or listener. Joining a Tailnet remains the operator's separate tailscale up decision.

Systemd enablement is local /etc state. It can persist across bootc rebases, so moving to an image with safer defaults does not automatically turn off a service that was previously enabled locally. Inspect with ujust tower-health and use sudo systemctl disable --now <unit> when intentionally removing a prior opt-in.

Rootless container templates

Inert, hardened templates live in /usr/share/bazzite-tower/examples/containers/ (source: system_files/usr/share/bazzite-tower/examples/containers/). They are outside Quadlet discovery paths and create no service. Copy a Quadlet example to ~/.config/containers/systemd/, replace its invalid digest and paths, then reload the user manager. The Compose example uses a user-owned secret file; never add a credential to the image or repository.

Disabled-by-default external repos

External repos (currently just Docker CE) are dropped on disk with enabled=0. Packages are pulled via --enablerepo= flags during the build transaction only. Result: zero background traffic to external repos, no surprise upgrades, no third-party participation in runtime bootc upgrade.

Packages explicitly excluded

To keep the image lean and focused, these are not installed even though some sibling images include them: python3-ramalama, bcc, bpftrace, bpftop, tiptop, sysprof, nicstat, numactl, usbmuxd, VS Code. Install any of them via rpm-ostree install or flatpak as needed.

Opt-in endpoint events for Vigie (off by default)

The image ships the packages (rsyslog, rsyslog-mmjsonparse, rsyslog-gnutls) and, on the system profile, pristine copies of Snitchwatch's endpoint-events config and units under /usr/share/snitchwatch/endpoint-events/. Nothing is enabled, nothing listens on 514 or 6514, and opensnitchd has no Loggers entry. Turning it on is a manual operator step that follows Snitchwatch's packaging/endpoint-events/README.md; it is not automated here. Risks and the exact guarantees are in docs/RUNBOOK.md.

Continuous testing & upstream tracking

This image rides upstream Bazzite's base and the laptop rebases onto :latest, so "the build is green" has to also mean "the image works." A green build can still publish a silently-broken image — e.g. an upstream change makes the qemu-user logic create nothing, virtqemud crash-loops on boot, and qemu:///system never comes up, yet nothing ever errors at build time. Three layers guard that gap; the full failure model and the reasoning behind each layer live in docs/downstream-change-tracking.md.

Layer Where What it does
Smoke gate base-refresh.yml / build.yml → tests/smoke.sh Offline assertions against the freshly built image, run before push: qemu user resolves, the six virt*.sockets are enabled, libvirtd is masked, the Wi-Fi guard / first-boot units are enabled, Docker/Cockpit/Waydroid remain disabled, and the IOMMU / i915 / suspend kargs are present. A failure blocks the push, so :latest stays on the last-good image.
Runtime promotion gate base-refresh.yml / build.yml → tests/boot-check.sh Boots each freshly built matrix candidate under podman --systemd=always before any registry login or tag promotion. It proves the stack works: socket-activates virtqemud, connects to qemu:///system, and confirms the Wi-Fi backend guard ran clean. boot-test.yml remains an independent scheduled diagnostic.
Upstream early warning base-watch.yml → ci/base-diff.py Daily, diffs the base image's package manifest (committed to docs/manifests/ after the first run) against the last-seen one, filtered to the blast-radius packages (qemu/libvirt/NetworkManager/Docker/kernel/systemd/polkit/bootc). A change opens a heads-up issue before the next build.

The build, boot-test and base-watch workflows maintain labelled tracking issues (ci-failure, boot-test-failure, base-bump). Automatic refresh failures appear in the Actions run summary. Reproduce the smoke gate locally with just smoke.

Installing

From any bootc-based system (Bazzite, Bluefin, Aurora, Silverblue, Fedora Atomic):

From a trusted checkout of this repository, bootstrap its repository-scoped policy on the host before the first switch:

sudo ./scripts/install-signature-policy.sh
sudo bootc switch --enforce-container-sigpolicy ghcr.io/bearyjd/bazzite-tower:latest
sudo systemctl reboot

The image is signed with cosign — the public key lives at cosign.pub. The installed policy rejects by default, verifies this repository's more-specific sigstoreSigned rule, and preserves unrelated explicit host rules. It retains only a Docker-transport empty-scope compatibility fallback for unrelated Docker/Podman pulls; that fallback never applies to this repository's specific rule. It cannot verify the first image retroactively when it lives only inside that image, which is why the host bootstrap step is required. Subsequent upgrades retain the policy in /etc.

Tags

Three channels use the same Containerfile, with separate accepted product profiles. .github/base-refresh-policy.json freezes the product source at 4b963674045214ca7d79f628270c9c8cf6b47f28.

The r16.1 policy transition must merge only after protected fixed-digest promotion completes and independent destination signature/SPDX checks pass. Host runtime acceptance remains unverified; the owner authorized the CI-based release path.

stable — automatic Bazzite refresh of the accepted legacy product

  • stable and compatibility alias latest select the same legacy product (SNITCHWATCH_BRIDGE=legacy, IMAGE_TAG=latest). Existing :latest hosts continue receiving this channel.
  • base-refresh.yml runs daily and by dispatch on main. It resolves upstream bazzite-nvidia-open:stable to an immutable digest and overrides BASE_IMAGE in the accepted source checkout. It rejects bases below Bazzite 44.20261023 / kernel 7.2.9-ogc3.1.fc44 and stale policy before publication.

latest-kernel — opt-in development channel

  • latest-kernel / latest-kernel.YYYYMMDD / latest-kernel-YYYYMMDD / latest-kernel-<short-sha>
  • build.yml publishes this channel from current main, using upstream bazzite-nvidia-open:stable. It does not move the accepted product channels. Check docs/research/i915-bug-report/ before booting it on the ThinkPad P1.

snitchwatch-system — automatic Bazzite refresh of the r16.1 system product

  • Remains a separate opt-in channel; it is not an alias for stable.
  • Uses the reviewed r16.1 system profile with SNITCHWATCH_BRIDGE=system and FIREWALL_DAEMON=opensnitch, refreshed by base-refresh.yml against the same immutable upstream base. Both profiles retain DefaultAction: allow and ProcMonitorMethod: proc; these gates do not establish host eBPF compatibility.
  • Switching is not just bootc switch. /etc survives bootc rollback, so the profile change has its own transaction (RUNBOOK, "OpenSnitch / Snitchwatch deployment status"). Order: sudo bootc switch --enforce-container-sigpolicy ghcr.io/bearyjd/bazzite-tower:snitchwatch-system, reboot, ujust snitchwatch-system-migrate check, then apply. To go back, run ujust snitchwatch-system-migrate rollback first, then switch to :latest or bootc rollback; otherwise the legacy image boots with the system socket address in /etc/opensnitchd/default-config.json and the firewall silently fails open.

Each base refresh must pass image build, smoke/runtime checks, signing, SPDX, provenance and host signature policy checks before its tags advance. No owner click is required for each base refresh. Accepting new Snitchwatch features still requires owner runtime acceptance and the protected release promoter, followed by a reviewed policy update. The weekly base-bump PR maintains the historical Containerfile default pin. Automatic accepted-product refresh always overrides that pin with the separately resolved immutable upstream base; the reviewed refresh policy determines the product source.

Hardware target

  • Desktop or laptop (not handheld / Deck)
  • NVIDIA GPU on the open kernel modules (developed against an RTX 4070 Max-Q / Ada on a hybrid Optimus laptop)
  • KVM-capable CPU (Intel VT-x or AMD-V)
  • Sufficient RAM for KDE Plasma + concurrent VMs

The open kernel modules are NVIDIA's default for Turing and newer — there's a hard cutoff below Turing (Maxwell/Pascal/Volta aren't supported). If your card predates Turing, or you'd rather run the proprietary driver (see Design choices for the trade-offs), swap the Containerfile FROM to bazzite-nvidia:stable and rebuild.

Repository layout

Path Purpose
Containerfile Image build definition (FROM + COPY system_files + invoke build.sh)
build_files/build.sh Thin runner — executes every build_files/build.d/*.sh in filename order
build_files/build.d/ All customizations, one concern per script: packages, repos, units, polkit, firewall
system_files/ Static content copied verbatim into the image (systemd units, ujust recipes, bootc kargs)
disk_config/disk.toml qcow2/raw config for bootc-image-builder
disk_config/iso-kde.toml bootc-image-builder anaconda-iso config (unused — see ISO note)
disk_config/iso-gnome.toml bootc-image-builder anaconda-iso config (unused — see ISO note)
installer/ Live-ISO payload (live KDE session + Anaconda) built FROM bazzite-tower, fed to titanoboa by build-iso.yml / just build-iso-live
.github/workflows/build.yml CI: build, smoke-test gate, push to GHCR, sign with cosign
.github/workflows/build-disk.yml CI: produce a qcow2 disk image on demand (anaconda-iso disabled — upstream blockers)
.github/workflows/build-iso.yml CI: build a bootable, Secure-Boot live/installer ISO via titanoboa
.github/workflows/boot-test.yml CI: boot the image under systemd and check runtime behaviour
.github/workflows/base-watch.yml CI: daily upstream base package-diff early warning
tests/smoke.sh Offline assertions run against the built image (the CI gate; also just smoke)
tests/boot-check.sh Runtime checks run inside the booted image by boot-test.yml
ci/base-diff.py Filters the upstream package diff to the blast-radius packages
docs/downstream-change-tracking.md How the image stays current with upstream Bazzite without silently breaking
cosign.pub Public key for verifying signed images
Justfile Local build/run recipes (see below)

Local build & VM testing

Quick path for testing changes before rebasing your real machine:

just build               # build the container image locally
just smoke               # offline smoke-test the built image (same assertions as the CI gate)
just build-qcow2         # turn it into a bootable qcow2
just run-vm-qcow2        # boot the qcow2 in qemu, browser console at localhost:8006

just spawn-vm boots via systemd-vmspawn instead, if you'd rather skip the browser console. Run just with no arguments for the full recipe list. Detailed Justfile documentation is below.


Repository Contents

Containerfile

The Containerfile defines the operations used to customize the selected image. This file is the entrypoint for the image build and works exactly like a regular podman Containerfile. For reference, see the Podman Documentation.

build.sh and build.d/

The build.sh file is called from the Containerfile, but it is now a thin runner: it executes every script in build_files/build.d/ in filename order. Each of those scripts owns one concern — 30-docker-ce.sh, 40-sysusers-fixup.sh, 95-firewall.sh and so on — so to change what's in the image, edit the file that owns that concern, not the runner.

The numbers are execution order, and order matters: the sysusers fixup (40-) has to run after the Docker install (30-) and before libvirt daemon enablement (60-). Add a new script at the number that puts it in the right place rather than appending one.

build.yml

The build.yml GitHub Actions workflow creates the custom OCI image and publishes it to the GitHub Container Registry (GHCR). The image name matches the GitHub repository name. Several environment variables at the start of the workflow may be of interest to change.

Building Disk Images

This template provides an out-of-the-box workflow for creating disk images (ISO, qcow, raw) for the custom OCI image, which can be used to directly install onto machines.

This template provides a way to upload the disk images generated from the workflow to an S3 bucket. The disk images will also be available as artifacts from the job if you wish to use an alternate provider. To upload to S3 we use rclone, which supports many S3 providers.

Setting Up Disk Image Builds

The build-disk.yml GitHub Actions workflow creates a disk image from your OCI image using the bootc-image-builder. To use this workflow:

  1. Two artifacts, two tools. build-disk.yml builds a qcow2 (rootfs=btrfs) for VM testing. The image's /usr/lib/bootc/install/50-signature-policy.toml asks unattended bootc disk installations to enforce its container policy; disk_config/disk.toml intentionally does not duplicate that image-owned setting. Bootable ISOs are built separately by build-iso.yml using titanoboa (ublue's live-ISO toolchain), not bootc-image-builder's anaconda-iso — that path is upstream-broken (BIB#1188, bazzite#3418). The ISO is built from the installer/ payload image (a live KDE session + Anaconda that installs bazzite-tower via ostreecontainer); it boots under Secure Boot (the payload swaps in a Fedora-signed kernel) and can be built locally with just build-iso-live. That ISO has a separate installer path: Secure Boot validates its boot chain, but does not by itself prove the OCI image's sigstore policy was enforced. The iso-kde.toml/iso-gnome.toml files are leftover BIB configs and are unused. For an existing bootc system, bootc switch (see Installing) is still the simplest path.
  2. If you changed your image name from the default in build.yml, then in build-disk.yml edit the IMAGE_REGISTRY, IMAGE_NAME, and DEFAULT_TAG environment variables to match. If you didn't, skip this step.
  3. If you want to upload your disk images to S3, add the S3 configuration to the repository's Action secrets (Settings → Secrets and Variables → Actions):
    • S3_PROVIDER — must match one of the values from the supported list
    • S3_BUCKET_NAME — your unique bucket name
    • S3_ACCESS_KEY_ID — recommended to make a separate key for this workflow
    • S3_SECRET_ACCESS_KEY — see above
    • S3_REGION — the region your bucket lives in (set to auto if you don't know)
    • S3_ENDPOINT — provider-specific endpoint URL

Once the workflow is done, disk images land either in your S3 bucket or as part of the run summary under Artifacts.

Justfile Documentation

The Justfile contains commands and configurations for building and managing container images and virtual machine images using Podman and other utilities. To use it you must have just installed from your package manager or manually. It's available by default on all Universal Blue images.

Environment Variables

  • image_name — the name of the image (default: bazzite-tower)
  • default_tag — the default tag for the image (default: latest)
  • bib_image — the Bootc Image Builder image (default: digest-pinned quay.io/centos-bootc/bootc-image-builder@sha256:2b52843ea2bfda73b0a08d97e76b734393b1d3a804681b9fabb26723bd3a2f0b)

Building The Image

just build

Builds a container image using Podman.

just build $target_image $tag

Arguments:

  • $target_image — the tag to apply to the image (default: $image_name)
  • $tag — the tag for the image (default: $default_tag)

just build-portmaster-spike

Builds the opt-in Portmaster experiment with its service disabled. It is for a disposable VM only: it does not alter :latest and must not be switched onto the laptop. Follow the VM spike runbook after building it.

just build-portmaster-spike
just smoke image_name portmaster-spike

Boot it with just run-vm-ssh (see below) rather than run-vm-qcow2/spawn-vm — it prints a working ssh command instead of requiring a GUI console. Inside the VM, the verdict comes from sudo tests/portmaster-vm-gate.sh, which exits 0 only if every check passes. Do not judge the spike by watching systemctl status: the 2026-08-05 run was recorded as a pass because is-active was read before the daemon exited.

Its config is deliberately not writable at runtime. The unit BindReadOnlyPaths=-mounts /usr/share/bazzite-tower/portmaster-config.default.json over /var/lib/portmaster/config.json, so the update pin lives in the image and reverts with a rollback. Editing the file under /var has no effect — change the /usr copy and rebuild. core/automaticUpdates defaults to true upstream, so a config the image cannot control means automatic updates are on.

just smoke

Runs the offline smoke test (tests/smoke.sh) against a built image — the same assertions as the CI promotion gate, with no VM required.

just smoke $target_image $tag

It executes podman run --rm -i "$target_image:$tag" bash -s < tests/smoke.sh, so build the image first (just build). Exits non-zero if any customization (qemu user, modular virt*.sockets, the Wi-Fi guard, the IOMMU / i915 / suspend kargs, Docker CE) is missing.

Building and Running Virtual Machines and ISOs

The commands below build QCOW2 images by default. To produce or use a different type of image, substitute qcow2 with that type. Available types: qcow2, iso, raw.

just build-qcow2

Builds a QCOW2 virtual machine image.

just build-qcow2 $target_image $tag

just rebuild-qcow2

Rebuilds a QCOW2 virtual machine image.

just rebuild-vm $target_image $tag

just run-vm-qcow2

Runs a virtual machine from a QCOW2 image.

just run-vm-qcow2 $target_image $tag

just spawn-vm

Runs a virtual machine using systemd-vmspawn.

just spawn-vm rebuild="0" type="qcow2" ram="6G"

just run-vm-ssh

Boots a login/SSH-capable VM for interactive testing (e.g. the Portmaster spike gate) — no GUI console, no clipboard needed. Requires the image to be built with --build-arg VM_GATE_SSH=1 (build-portmaster-spike already does this); prints the ssh command to connect once it's up.

just run-vm-ssh $target_image $tag $ssh_port

Arguments:

  • $target_image — the tag to boot (default: $image_name)
  • $tag — the tag to boot (default: portmaster-spike)
  • $ssh_port — host port to forward to the guest's 22 (default: 2222)

Uses disk_config/vm-test.toml, not disk.toml (what ships, what the ThinkPad boots, and which has neither a user nor sshd), and generates a fresh ephemeral SSH keypair every run. See the Justfile recipe's own comment and the VM spike runbook's "VM-gate SSH" section for why it boots with plain qemu-system-x86_64 instead of run-vm-qcow2 or spawn-vm.

File Management

just check

Checks the syntax of all .just files and the Justfile.

just fix

Fixes the syntax of all .just files and the Justfile.

just clean

Cleans the repository by removing build artifacts.

just lint

Runs shellcheck on all Bash scripts.

just format

Runs shfmt on all Bash scripts.

just test-ci

Runs fixture-based tests for ci/base-diff.py against committed manifest pairs under tests/fixtures/base-diff/ — offline, no live upstream manifests needed.

just test-snitchwatch-daemon-patch

Runs tests/test-snitchwatch-daemon-patch.sh: applies the pinned downstream OpenSnitch patch to its pinned upstream commit (through the daemon factory's own source checks) and runs the patch's Go tests (with -race; the C harness tests it builds run without a sanitizer), in two throwaway containers from the pinned Fedora builder — one with network to fetch, one with --network=none to test. No image build. The Go/protoc/libnetfilter_queue toolchain is the same pinned, signature-verified Koji RPM set the image build uses (fetched with network, installed offline); exit 3 would mean the installed toolchain disagrees with the pins, not a test failure. Upstream's three ui tests and four procmon TestCacheEvents* tests that also fail under -race on the unpatched commit are skipped by name in the -race pass only. Upstream's privileged nftables tests skip too, as they need PRIVILEGED_TESTS and namespace creation.

Additional resources

For additional driver support, ublue maintains a set of scripts and container images at ublue-akmods. These images include scripts to install multiple kernel drivers within the container (Nvidia, OpenRazer, Framework, etc.) — useful if you need to extend bazzite-tower with additional hardware support.


Originally derived from the ublue-os/image-template. Community resources: Universal Blue Forums, Universal Blue Discord, bootc discussion forums.

About

Personal Bazzite bootc desktop image (KDE + NVIDIA) hardware-tuned for a Meteor Lake ThinkPad: VFIO/QEMU/libvirt + Docker CE, Cockpit web management, dev tooling, audio/power/storage fixes, and a CI smoke/boot-test gate that blocks broken images.

Topics

Resources

Contributing

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages