A custom bootc image derived from ghcr.io/ublue-os/bazzite-nvidia:stable, 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 the proprietary NVIDIA driver rather than the open kernel modules: on hybrid (Optimus) laptops the proprietary driver is currently the more reliable choice for suspend/resume and power management, which the open modules still struggle with (see Design choices). If you prefer the open modules (NVIDIA's default for Turing+), rebase the FROM to bazzite-nvidia-open:stable.
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.
iptable_nat is registered in /etc/modules-load.d/iptable_nat.conf for docker-in-docker workloads.
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, docker (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 |
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).
NVIDIA's open kernel modules are the default for Turing+ since driver R560 and are at performance parity, so they're the obvious pick on paper. But this image targets a hybrid (Optimus) laptop where the priority is reliable host dGPU use — PRIME render offload plus dependable suspend/resume — not GPU passthrough. That's exactly where the open modules still lag: NVIDIA's own driver docs list power management as a known-incomplete area, and upstream open-gpu-kernel-modules bug reports of suspend/hibernate failures on Intel+NVIDIA hybrid laptops remained open into 2026. Bazzite users on hybrid laptops have reported better stability (and lower idle power) on the proprietary driver.
So bazzite-tower builds on bazzite-nvidia:stable (proprietary). On an RTX 40-series (Ada) card the proprietary driver is fully supported; the open modules remain one FROM-line swap away (bazzite-nvidia-open:stable) if you'd rather track NVIDIA's open default — and bootc rollback makes trying either low-risk.
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 corrupts the i915 PHY A / C10 (cx0) PLL state on s2idle resume (~30s of flip-done timeouts and a sluggish display after wake). Two more bootc kargs.d fragments address these:
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. (These mitigate the panel power-saving faults; they do not fix the PHY A resume regression on their own.)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). The PHY A resume regression itself needs an upstream kernel fix —scripts/check-i915-resume-fix.sh(weekly user timer) watches for it.
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,docker(adding only groups that exist). This grants real group membership for tools that checkgroups, for raw/dev/kvmaccess, and for the rootlessdockersocket (polkit only covers libvirt). 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) and rootless docker depend on group membership, so they work 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.service is enabled at boot, and the first regular user is added to the docker group (see below), so docker works without sudo after the first login cycle.
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.
This image rides bazzite-nvidia:stable 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 | 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 / Docker units are enabled, the IOMMU / i915 / suspend kargs are present. A failure blocks the push, so :latest stays on the last-good image. |
| Runtime boot test | boot-test.yml → tests/boot-check.sh |
Boots the image's own systemd under podman --systemd=always and proves the stack works: socket-activates virtqemud and connects to qemu:///system (the end-to-end check for the qemu-user regression), and confirms the Wi-Fi backend guard ran clean. |
| 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. |
Each failing layer opens — and later auto-closes — a labelled tracking issue (ci-failure, boot-test-failure, base-bump). Reproduce the smoke gate locally with just smoke.
From any bootc-based system (Bazzite, Bluefin, Aurora, Silverblue, Fedora Atomic):
sudo bootc switch ghcr.io/bearyjd/bazzite-tower:latest
sudo systemctl rebootThe image is signed with cosign — the public key lives at cosign.pub in this repo. Bazzite's bootc policy enforces signature verification by default.
latest— current build ofmainlatest.YYYYMMDD— same image, date-stampedYYYYMMDD— date-only tag<short-sha>— the 7-character git SHA of the build commit
CI rebuilds weekly (Sunday 06:00 UTC) and on every push to main.
- Desktop or laptop (not handheld / Deck)
- NVIDIA GPU on the proprietary driver (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 proprietary driver supports Maxwell and newer, so there's no pre-Turing cutoff here. If you'd rather run NVIDIA's open kernel modules (default for Turing+), swap the Containerfile FROM to bazzite-nvidia-open:stable and rebuild.
| Path | Purpose |
|---|---|
Containerfile |
Image build definition (FROM + COPY system_files + invoke build.sh) |
build_files/build.sh |
All customizations: packages, repos, units, polkit, first-boot oneshot |
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. It is where every customization in this image lives: package installs, repo files, systemd unit drops, polkit rules, and the first-boot oneshot. Edit this file to change what's in the image.
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. 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. 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:quay.io/centos-bootc/bootc-image-builder:latest)
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)
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"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.
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.