11 KiB
main-desktop
Custom Bazzite DX image for my desktop, built with BlueBuild, published signed (cosign) to the container registry on my Gitea instance (git.lazypugs.com) — built locally today, Gitea Actions-ready when a runner is registered.
- Base:
ghcr.io/ublue-os/bazzite-dx-nvidia— Bazzite DX, KDE Plasma, NVIDIA open kernel modules. There is no separate-openDX image anymore: DX NVIDIA is open-driver-only, which is exactly what the RTX 5090 (Blackwell) requires. Current stable ships driver 610.x (open). - Pinned to Fedora 44 via
image-version: stable-44in recipes/recipe.yml. No surprise major upgrades; bump tostable-45deliberately. - This image layers almost nothing. The base already includes Steam and the gaming stack,
Flatpak, Docker CE + CLI, Podman, VS Code, Homebrew, and nvidia-container-toolkit with
automatic CDI setup (
ublue-nvctk-cdi.serviceregenerates/etc/cdi/nvidia.yamleach boot). What this recipe adds: the cosign signature-verification policy and a declarative first-boot Flatpak set. That's it — by design.
Why BlueBuild instead of the raw Containerfile template: with the container toolkit already
in-image, the whole custom layer is "install these Flatpaks on first boot + trust my signature",
which the default-flatpaks and signing modules express declaratively in ~40 lines of YAML —
no hand-rolled first-boot systemd unit needed.
Building & publishing
Locally (current path)
The image is built and pushed from a workstation with the BlueBuild CLI driving Docker. From
the repo root (needs cosign.key present there, and ~25 GB free for layers):
# One-time CLI install (extracts a static binary to /usr/local/bin/bluebuild):
docker run --pull always --rm ghcr.io/blue-build/cli:latest-installer | bash
# Build, push, and sign in one shot:
BB_USERNAME=ckoch BB_PASSWORD=<gitea-PAT-with-write:package> \
bluebuild build --push --retry-push \
--registry git.lazypugs.com --registry-namespace ckoch \
recipes/recipe.yml
Tags are pinned in the recipe (alt-tags: latest, stable-44), so the pushed refs are the same
no matter where the build runs. Signing happens automatically because cosign.pub is in the
repo root and the key is available. Rebuild cadence is manual under this model — run the
build when you want base-image updates rolled in; the OS then picks them up on its normal
update timer.
Gitea Actions (optional, needs a runner)
.gitea/workflows/build.yml runs the same BlueBuild CLI build
(the blue-build/github-action is GitHub-only). It is currently workflow_dispatch-only
because no Actions runner is registered on the instance; when one exists, restore the
commented-out schedule + push triggers in the workflow to make builds fully automatic
(daily rebuilds = true self-updating).
Repo secrets (already configured):
SIGNING_SECRET— contents ofcosign.key(generated with an empty password; the public half is committed ascosign.pub). The CLI signs automatically on push when both are present.REGISTRY_TOKEN— a Gitea personal access token with write:package scope, used to push to the registry. Gitea's automatic per-job token cannot publish packages (documented Gitea limitation), hence the PAT. Currently seeded with a temporary token — before revoking it, create a durable PAT (Settings → Applications → Generate token,write:package) and update the secret (repo Settings → Actions → Secrets, orPUT /api/v1/repos/ckoch/main-desktop/actions/secrets/REGISTRY_TOKEN).
Runner requirements when you set one up (act_runner):
- Privileged job containers must be allowed — the build runs buildah inside the job
container (
ghcr.io/blue-build/cli). In the runner'sconfig.yaml:container: { privileged: true }. Without it the build fails at the buildah stage. - Internet access (pulls the multi-GB Bazzite base from ghcr.io) and ~25+ GB free scratch disk.
Rebasing the desktop onto this image
From the existing Bazzite install, rebase in two steps — first unsigned (this installs the image, which contains the signing policy and public key), then signed:
# Step 1: unsigned rebase, then reboot
rpm-ostree rebase ostree-unverified-registry:git.lazypugs.com/ckoch/main-desktop:latest
systemctl reboot
# Step 2: switch to the signed ref, then reboot
rpm-ostree rebase ostree-image-signed:docker://git.lazypugs.com/ckoch/main-desktop:latest
systemctl reboot
After step 2, every update is signature-verified. :latest is fine here — the Fedora pin lives
in the recipe, so latest never crosses a major until the recipe says so.
Roll back anytime with rpm-ostree rollback (or pick the previous deployment in the boot menu).
Notes for a self-hosted registry:
- The repo/package is public, so anonymous pulls work and the desktop needs no auth config. If
you ever make it private, the host needs credentials in
/etc/ostree/auth.json(containers-auth.json format) —podman loginalone does not cover OS updates. - Updates only flow while git.lazypugs.com is reachable; if the box is down, updates pause and retry later. The running system is unaffected.
Install-time checklist
-
Driver sanity:
nvidia-smireports driver ≥ 580 (currently 610.x) and the RTX 5090 is listed. Confirm open kernel modules:modinfo -F license nvidiashould sayDual MIT/GPL(the proprietary module saysNVIDIA). Blackwell only works on the open modules. -
Signature: verify the published image against the committed public key:
cosign verify --key cosign.pub git.lazypugs.com/ckoch/main-desktop:latest -
Deployment:
rpm-ostree statusshows the booted deployment isostree-image-signed:docker://git.lazypugs.com/ckoch/main-desktopand the base version matches a recent build. -
Flatpaks: the first-boot service installs the whole list below; give it a few minutes on first login (it notifies when done). Check with
flatpak list --system. -
Docker group: run
ujust dx-group(adds you todockerand friends), then log out/in. -
GPU in containers: see the section below — run the one-liner test.
GPU in containers (CUDA on the 5090)
nvidia-container-toolkit is already in the image and a boot service generates the CDI spec
automatically. CDI is the shipped mechanism:
# Podman — works out of the box:
podman run --rm --device nvidia.com/gpu=all docker.io/nvidia/cuda:12.8.0-base-ubuntu24.04 nvidia-smi
# Docker — CDI device syntax also works on current Docker CE:
docker run --rm --device nvidia.com/gpu=all nvidia/cuda:12.8.0-base-ubuntu24.04 nvidia-smi
If you want the classic docker run --gpus all syntax, that needs a one-time runtime hookup
(this writes to /etc/docker/daemon.json, which is mutable and survives updates):
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
First-boot Flatpaks
Installed system-wide from Flathub by the default-flatpaks module. All IDs verified against
Flathub (2026-07).
Official / verified listings: Bambu Studio, Bitwarden, Discord, Firefox, Thunderbird,
FreeCAD, GIMP, HandBrake, LibreOffice, Moonlight, PCSX2 (net.pcsx2.PCSX2 — the official
build), Podman Desktop, Warzone 2100, XIVLauncher (dev.goats.xivlauncher).
Community / unverified listings — know what you're running:
| App | ID | Note |
|---|---|---|
| GitHub Desktop | io.github.shiftey.Desktop |
Community Linux fork; the "shiftey" spelling is the real ID |
| MakeMKV | com.makemkv.MakeMKV |
Community packaging of the proprietary app |
| GeForce NOW | io.github.hmlendea.geforcenow-electron |
Unofficial Electron wrapper, not an NVIDIA product |
| VLC | org.videolan.VLC |
No verified badge on Flathub (still the VideoLAN-maintained build) |
| Godot | org.godotengine.Godot |
No verified badge on Flathub |
GeForce NOW on the 32:9 monitor: the service only streams 16:9 (and some 21:9) aspect ratios, so expect pillarboxing at 5120x1440 — that's a service limitation, not a config problem.
Post-install steps (manual by design — don't automate these)
PIA VPN — native WireGuard, not the app
The PIA desktop app installs into /usr and breaks on the read-only filesystem. Skip it:
- Log in at PIA's OpenVPN config generator (or generate a WireGuard config via their API/support flow) and download configs for your preferred regions.
- KDE System Settings → Network → Connections → Add (+) → Import VPN connection…, pick the downloaded file, and enter your PIA credentials.
- This is plain NetworkManager — it lives in
/etc, survives every update, and gets a connect/disconnect toggle in the system tray.
Microsoft Teams — PWA via Edge
No native Linux client. Install Microsoft Edge specifically (corporate conditional
access / Intune compatibility): flatpak install flathub com.microsoft.Edge, sign in to
https://teams.microsoft.com, then menu → Apps → Install this site as an app.
Claude desktop — PWA
No official Linux app. Open https://claude.ai in Edge (or any Chromium browser) and use Install this site as an app the same way.
Proton-GE
Already handled by Bazzite: use the preinstalled ProtonUp-Qt GUI (or browse ujust — run
ujust with no arguments to list recipes). Don't install anything extra.
Node / toolchains — not in the image
Node, npm, etc. are deliberately not layered. Use per-project versions instead:
brew install mise # Homebrew ships in the base image
mise use node@22 # per-project .mise.toml; `mise use -g node@22` for a global default
(Plain brew install node also works if you just want one global Node.)
Display note
The 49" 32:9 (5120x1440) ultrawide needs nothing at the image layer. Post-install: set scale and refresh rate in KDE System Settings → Display; KDE's window tiling (Meta+drag, or a tiler like Polonium) is worth setting up at this width. VMs are remote via Remmina — there is intentionally no local hypervisor tooling in this image.
Maintenance
- Change the Flatpak list / packages: edit recipes/recipe.yml, push,
rebuild (see "Building & publishing"), and the next OS update picks it up. (Removing an app
from the list does not uninstall it from the machine;
flatpak uninstallit once by hand.) - Jump Fedora majors: change
image-version: stable-44→stable-45in the recipe when ready, rebuild, then update normally. - Pull in base-image updates: just rebuild — the base tag is
stable-44, so each rebuild picks up the newest Bazzite build within Fedora 44. - Key hygiene:
cosign.keyis git-ignored and lives only on the workstation + in theSIGNING_SECRETsecret — keep a backup (password manager). Losing it means generating a new pair, updating the secret, and re-doing the two-step rebase to re-establish trust.