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.
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.
qemu-kvm,libvirt,libvirt-daemon-kvm,libvirt-clientlibvirt-daemon-config-network,libvirt-daemon-config-nwfilter(default NAT network + nwfilter rules)virt-manager,virt-install,virt-vieweredk2-ovmf(UEFI firmware for VMs)guestfs-tools,spice-gtk3
android-tools— adb/fastboot for device flashingflatpak-builder— build local Flatpaksrestic,rclone— backup and cloud synczsh— alternative shellccache— compile caching for native buildspodman-machine,podman-tui— Podman VM and TUI
Docker CE is installed from the upstream download.docker.com repo (not Fedora's moby-engine):
docker-ce,docker-ce-cli,containerd.iodocker-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 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:
- Install and enable the released
snitchwatch-bridge.service, then runujust opensnitch-readiness. It is read-only and succeeds only when OpenSnitch is active, remains configuredDefaultAction: allow, and the enabled active user service'sMainPIDresolves to the released bridge binary and owns127.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-cliand 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. - 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.
- Only after both checks succeed should an operator consider deny. The image still ships
DefaultAction: allowin 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 onlyDefaultActiontodeny, restarts OpenSnitch and arms an automatic revert; answer a prompt in Snitchwatch and runujust snitchwatch-deny confirmto keep it, otherwise it reverts at the deadline or at the next boot (before the firewall starts).ujust snitchwatch-deny offreturns toallow;ujust snitchwatch-deny statusshows state (sudo /usr/libexec/bazzite-tower-snitchwatch-deny status --jsonfor a stable machine-readable form). Readiness accepts a confirmed deny asdeny (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.
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.
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:
nmclishows the device asunavailable(notdisconnected), but the driver-level scan inwifi-debugfinds 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.confsetswifi.backend=iwdwhileiwdis inactive (wpa_supplicantis what's actually running). This frequently happens after abootcrebase, because/etcpersists: awifi.backend=iwdfile from a previous image survives, but the enablediwdservice does not.bazzite-towerships 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 withsudo mv /etc/NetworkManager/conf.d/iwd.conf ~/iwd.conf.disabled && sudo systemctl restart NetworkManager(orsudo systemctl enable --now iwdif you actually wantiwd).rfkillshows a hard block → a physical/Fn switch or a BIOS setting, not the image.lspcidoesn't list the wireless card at all → disabled in BIOS or a hardware/seating issue.lspcilists the card butdmesgshowsiwlwifi ... DMARfaults orFailed to start ... ucode→ the IOMMU is knocking outiwlwifi. An Intel CNVi card can fail to initialize underintel_iommu=on, and the wireless device then never registers. Confirm by rebooting, pressingein GRUB, removingintel_iommu=on iommu=ptfrom thelinuxline, 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
iwlmldop-mode and require firmware ≥ v100 — there is no usableiwlmvmfallback, so don't bother downgrading firmware. Ifwifi-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).
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-opentracks NVIDIA's New Feature Branch (currently610.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 that610added). 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 oldersupergfxctl, 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.
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.
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.
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.
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=0disable 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=s2idlepins 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 withcat /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).
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:
- Polkit rule (
/etc/polkit-1/rules.d/50-libvirt-wheel.rules) — grantsunix-group:wheelaccess toorg.libvirt.unix.manageandorg.libvirt.unix.monitor. Anyone inwheelcan talk toqemu:///systemfromvirt-managerandvirshimmediately, no logout required. - First-boot oneshot (
bazzite-tower-firstboot.service) — runs aftersystemd-user-sessions.service, finds the first UID≥1000 user, and runsusermod -aG kvm,libvirt(adding only groups that exist). This grants real group membership for tools that checkgroupsand for raw/dev/kvmaccess. 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.
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.
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.
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.
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.
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.
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.
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.
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 rebootThe 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.
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
stableand compatibility aliaslatestselect the same legacy product (SNITCHWATCH_BRIDGE=legacy,IMAGE_TAG=latest). Existing:latesthosts continue receiving this channel.base-refresh.ymlruns daily and by dispatch onmain. It resolves upstreambazzite-nvidia-open:stableto an immutable digest and overridesBASE_IMAGEin the accepted source checkout. It rejects bases below Bazzite44.20261023/ kernel7.2.9-ogc3.1.fc44and stale policy before publication.
latest-kernel — opt-in development channel
latest-kernel/latest-kernel.YYYYMMDD/latest-kernel-YYYYMMDD/latest-kernel-<short-sha>build.ymlpublishes this channel from currentmain, using upstreambazzite-nvidia-open:stable. It does not move the accepted product channels. Checkdocs/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=systemandFIREWALL_DAEMON=opensnitch, refreshed bybase-refresh.ymlagainst the same immutable upstream base. Both profiles retainDefaultAction: allowandProcMonitorMethod: proc; these gates do not establish host eBPF compatibility. - Switching is not just
bootc switch./etcsurvivesbootc 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, thenapply. To go back, runujust snitchwatch-system-migrate rollbackfirst, then switch to:latestorbootc rollback; otherwise the legacy image boots with the system socket address in/etc/opensnitchd/default-config.jsonand 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.
- 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.
| 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) |
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:8006just 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.
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.
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.
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.
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.
The build-disk.yml GitHub Actions workflow creates a disk image from your OCI image using the bootc-image-builder. To use this workflow:
- Two artifacts, two tools.
build-disk.ymlbuilds a qcow2 (rootfs=btrfs) for VM testing. The image's/usr/lib/bootc/install/50-signature-policy.tomlasks unattended bootc disk installations to enforce its container policy;disk_config/disk.tomlintentionally does not duplicate that image-owned setting. Bootable ISOs are built separately bybuild-iso.ymlusing titanoboa (ublue's live-ISO toolchain), notbootc-image-builder'sanaconda-iso— that path is upstream-broken (BIB#1188, bazzite#3418). The ISO is built from theinstaller/payload image (a live KDE session + Anaconda that installs bazzite-tower viaostreecontainer); it boots under Secure Boot (the payload swaps in a Fedora-signed kernel) and can be built locally withjust 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. Theiso-kde.toml/iso-gnome.tomlfiles are leftover BIB configs and are unused. For an existing bootc system,bootc switch(see Installing) is still the simplest path. - If you changed your image name from the default in
build.yml, then inbuild-disk.ymledit theIMAGE_REGISTRY,IMAGE_NAME, andDEFAULT_TAGenvironment variables to match. If you didn't, skip this step. - 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 listS3_BUCKET_NAME— your unique bucket nameS3_ACCESS_KEY_ID— recommended to make a separate key for this workflowS3_SECRET_ACCESS_KEY— see aboveS3_REGION— the region your bucket lives in (set toautoif 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.
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.
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-pinnedquay.io/centos-bootc/bootc-image-builder@sha256:2b52843ea2bfda73b0a08d97e76b734393b1d3a804681b9fabb26723bd3a2f0b)
Builds a container image using Podman.
just build $target_image $tagArguments:
$target_image— the tag to apply to the image (default:$image_name)$tag— the tag for the image (default:$default_tag)
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-spikeBoot 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.
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 $tagIt 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.
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.
Builds a QCOW2 virtual machine image.
just build-qcow2 $target_image $tagRebuilds a QCOW2 virtual machine image.
just rebuild-vm $target_image $tagRuns a virtual machine from a QCOW2 image.
just run-vm-qcow2 $target_image $tagRuns a virtual machine using systemd-vmspawn.
just spawn-vm rebuild="0" type="qcow2" ram="6G"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_portArguments:
$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's22(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.
Checks the syntax of all .just files and the Justfile.
Fixes the syntax of all .just files and the Justfile.
Cleans the repository by removing build artifacts.
Runs shellcheck on all Bash scripts.
Runs shfmt on all Bash scripts.
Runs fixture-based tests for ci/base-diff.py against committed manifest pairs under tests/fixtures/base-diff/ — offline, no live upstream manifests needed.
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.
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.