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 -open DX 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-44 in recipes/recipe.yml. No surprise major upgrades; bump to stable-45 deliberately.
  • 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.service regenerates /etc/cdi/nvidia.yaml each 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 of cosign.key (generated with an empty password; the public half is committed as cosign.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, or PUT /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's config.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 login alone 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

  1. Driver sanity: nvidia-smi reports driver ≥ 580 (currently 610.x) and the RTX 5090 is listed. Confirm open kernel modules: modinfo -F license nvidia should say Dual MIT/GPL (the proprietary module says NVIDIA). Blackwell only works on the open modules.

  2. Signature: verify the published image against the committed public key:

    cosign verify --key cosign.pub git.lazypugs.com/ckoch/main-desktop:latest
    
  3. Deployment: rpm-ostree status shows the booted deployment is ostree-image-signed:docker://git.lazypugs.com/ckoch/main-desktop and the base version matches a recent build.

  4. 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.

  5. Docker group: run ujust dx-group (adds you to docker and friends), then log out/in.

  6. 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:

  1. 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.
  2. KDE System Settings → Network → Connections → Add (+) → Import VPN connection…, pick the downloaded file, and enter your PIA credentials.
  3. 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 uninstall it once by hand.)
  • Jump Fedora majors: change image-version: stable-44stable-45 in 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.key is git-ignored and lives only on the workstation + in the SIGNING_SECRET secret — 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.
S
Description
Custom Bazzite DX NVIDIA (open) bootc image — BlueBuild, signed
Readme
78 KiB
Languages
Shell 100%