However you launch it, the guided menu is the same.
Create Image scans your running system first. It reads the image you are on and any packages you have layered, so it already knows the base — it never asks you to choose one. That matters: Universal Blue images are not rebase-compatible with each other, so an image built on the wrong base is one you cannot switch to.
It works whether or not you have layered anything. With nothing layered it simply starts from your current base, and you add what you want from there — packages, COPR repositories, systemd services, base-package removals.
The main menu lists every base image the tool supports, so you can see up front whether your system is one of them.
If the scan cannot run, the tool says so and offers to let you pick a base
image by hand instead. That happens with a bare podman run, which has no
access to your host's state — the aib wrapper and distrobox both hand it in,
so neither hits this. See
container limitations.
The first successful build publishes the image to GHCR as a private
package. That is GitHub's default for a newly published package, and it is a
separate setting from the repository's own visibility — a public repository
does not publish public packages. So a green build is not yet a switchable
image: sudo bootc switch on a machine with no registry credentials cannot
read it.
Make it readable once, from the package's own page:
- Open
https://github.com/<your-user>/<your-repo>/pkgs/container/<your-repo> - Package settings -> Change visibility -> Public
The tool checks this for you. After a successful build, View build status tries the same anonymous pull your machine would make and says so if it cannot read the image — and stops saying it once the package is public.
Keeping the package private is a fine choice, but then the machine needs GHCR pull credentials for root before the switch works. See bootc's registry documentation.
- The tool creates a public GitHub repo under your account, and GitHub Actions builds the image after creation. Scheduled rebuilds also run daily on GitHub.
- The main menu can show recent GitHub Actions build status for a configured repo.
- Repos can be test-built locally with Podman before you push, whichever
build method they use — from a source checkout (see
container limitations).
A BlueBuild repo is rendered with the same BlueBuild CLI GitHub Actions
uses, pulled from its installer image, and then built with Podman. The
build renders this tool's current template plus your repo's
cosign.pub; edits you made by hand to other files in your clone (for example underfiles/system/) are not part of the local test, while in CI they are. - The update menu can rotate the repo's cosign signing key and update
cosign.pub. - The disk-image workflow (
build-disk.yml) and the localjust build-qcow2both runbootc-image-builderby digest rather than by tag, for the same reason the Homebrew layer below is: it produces every qcow2 and installer ISO your repository publishes. The pinned digest moves when this tool is updated, and an update re-pins a repository it created earlier.
If you use the scan flow to carry layered packages from your current system into the new image, first follow Trusting The Signing Key in the generated repo's README. Then run these in the same session before rebooting:
sudo rpm-ostree reset
sudo bootc switch --enforce-container-sigpolicy ghcr.io/<your-user>/<your-repo>:latest
systemctl reboot--enforce-container-sigpolicy verifies the switch against that repository's
signing key and keeps verification enabled for every later bootc upgrade.
Dropping the flag disables signature verification for both the switch and those
upgrades.
That clears the old layered package state from the current deployment before you
switch to the image-based version of those changes. You do not need to reboot
between rpm-ostree reset and bootc switch.
rpm-ostree reset with no category flags clears every layered package,
override and initramfs customization on the deployment, not only the ones the
image reproduces. The scan says so up front: it can carry packages requested
from a repository and base-package removals, and it stops to ask before
continuing when it finds anything else — a locally installed RPM, a package
replaced by a local build, a regenerated initramfs. Those are pinned to files
on your machine, so no generated image reproduces them. Run rpm-ostree status
before the reset if you want the full list.
This is about adding Homebrew to the image you build — not about installing this tool with Homebrew, which is covered in Installing.
Universal Blue images ship with Homebrew (brew) already integrated. Fedora Atomic images do not.
When you choose a Fedora Atomic base image (Silverblue, Kinoite, etc.), the tool
offers to include Homebrew using the Universal Blue brew OCI layer
(ghcr.io/ublue-os/brew). The generated Containerfile names it by digest rather
than by tag, because its whole /system_files tree is copied into / of an
image your workflow then signs — a tag that moved upstream would change what
that signature covers without changing anything in your repository. The pinned
digest moves when this tool is updated, after the new payload has been reviewed.
This adds:
- The Homebrew installation and
/etc/profile.d/brew-path.sh, the one shell integration fragment the generated image keeps brew-setup.servicefor first-boot initializationbrew-update.timerandbrew-upgrade.timerfor automatic maintenance
This option is skipped automatically for Universal Blue base images since they already include Homebrew. You can also toggle it later through the update menu.
The brew layer's /system_files carries three login-shell fragments alongside
the units and the tarball — /etc/profile.d/brew.sh,
/etc/profile.d/brew-bash-completion.sh and
/usr/share/fish/vendor_conf.d/ublue-brew.fish. All three execute code out of
/home/linuxbrew/.linuxbrew, and brew-setup.service ends with
chown -R 1000:1000 /home/linuxbrew, so on a booted machine that prefix is
owned by the desktop user. /etc/profile.d and the fish vendor directory are
read by every login shell, root's included (su -, sudo -i, a console or
SSH root login) — so as shipped, whoever owns the prefix chooses what root
executes, with no password and no sudo record.
The generated Containerfile therefore removes the three, and writes
/etc/profile.d/brew-path.sh in their place. That fragment only extends PATH,
and only when the prefix is owned by the account whose shell it is (test -O),
so brew still works for the desktop user and no login shell runs anything out
of a user-writable prefix. The build then greps the login-shell directories and
fails if anything else still mentions brew: the layer is an image this tool does
not build, pulled by a mutable tag, so a later digest can add a fourth fragment
without anything here changing.
Two consequences are worth knowing:
brew's bash completion is not installed. The way the layer provided it was to source files out of the prefix itself, which is the vulnerability.- fish does not read
/etc/profile.d, so fish users get no automaticPATHentry. Add one in your ownfiles/system_filesoverlay if you want it, guarded the same way.
The layer's brew-setup.service unpacks the 154 MB Homebrew tarball on first
boot, as root, staging it through /tmp/homebrew:
ExecStart=/usr/bin/mkdir -p /tmp/homebrew
ExecStart=/usr/bin/tar --zstd -xf /usr/share/homebrew.tar.zst -C /tmp/homebrew
ExecStart=/usr/bin/cp -R -n /tmp/homebrew/home/linuxbrew/.linuxbrew /home/linuxbrew
ExecStart=/usr/bin/chown -R 1000:1000 /home/linuxbrewThe unit ships no PrivateTmp=, and /tmp on a booted system is a
world-writable tmpfs. mkdir -p exits 0 on an existing symlink rather than
replacing it, so an account that creates /tmp/homebrew first has root extract
through its symlink, has whatever else it left there copied into
/home/linuxbrew, and has the result handed to UID 1000 by the chown. The
-n protects nothing on the run that matters — the unit's own
ConditionPathExists=!/home/linuxbrew/.linuxbrew guarantees the destination
does not exist yet.
The generated Containerfile therefore writes
/usr/lib/systemd/system/brew-setup.service.d/10-private-tmp.conf, carrying a
single PrivateTmp=yes. The unit gets its own /tmp and /var/tmp for the
whole invocation, shared by every ExecStart= of that invocation and by nothing
else on the system, so the staging path stops being a name another account can
claim. /home/linuxbrew is outside both, so the payload still lands where it
should.
A drop-in rather than a unit that overrides ExecStart=: a copy of the layer's
command chain would drift silently the next time the layer moves. The same build
step therefore fails if the layer stops shipping brew-setup.service, or if
none of that unit's ExecStart= lines stage under /tmp or /var/tmp — the
only two directories PrivateTmp= covers. As with the fragment sweep above, the
layer is pulled by a mutable tag, so a payload that moved has to stop the build
for review rather than ship beside a drop-in that no longer protects anything.