Fedora Atomic: A simple guide to creating a custom, local bootc image

Preface

On the Universal Blue Discourse, I saw this very simple guide to creating your own bootc image that I wanted to share: Locally built, automatically updating custom bootc image - General - Universal Blue

Notably, this does not rely on something like Github Actions to build your image. So it’s more private, not reliant on the whims of Microsoft, and likely faster.

Warning

Warning: this is unofficial and you should only do this if you are aware of the risks.

Before doing anything, it’s a good idea to pin your existing version.

# if this fails, try 1 instead
sudo ostree admin pin 0

My version of the guide

To start, create a containerfile. Here’s an example of one based on mine.

# This is located at /etc/system-image - DO NOT INCLUDE THIS LINE
FROM quay.io/fedora/fedora-silverblue:44

RUN --mount=type=bind,source=scripts,target=/tmp/scripts \
    --mount=type=cache,destination=/var/cache/libdnf5 \
    --mount=type=cache,destination=/var/lib/dnf5 \
    --mount=type=tmpfs,destination=/var/log \
    <<EOF
# bash safety options
set -euox pipefail

# this containerfile mounts ./scripts to /tmp/scripts so that you don't
# have to put everything in this containerfile
bash /tmp/scripts/myscript.sh

# remove packages
dnf5 remove -y gnome-software

# enable repos
dnf5 copr enable -y scottames/ghostty

# install packages
dnf5 install -y fastfetch ghostty zsh zsh-autosuggestions zsh-syntax-highlighting

EOF

RUN bootc container lint

Now, we create a systemd service to build that containerfile into an image.

# This is located at /etc/containers/systemd/system.build - DO NOT INCLUDE THIS LINE
[Build]
Arch=amd64
ImageTag=localhost/system-image
Pull=newer
SetWorkingDirectory=/etc/system-image
PodmanArgs=--squash

[Service]
ExecStartPost=/usr/bin/bootc switch --quiet --transport=containers-storage localhost/system-image:latest
ExecStartPost=/usr/bin/bootc update --quiet
ExecStartPost=/usr/bin/podman image prune --force --filter=label=containers.bootc=1
Nice=0

We will also want a timer so that systemd will automatically build new images.

# This is located at /etc/systemd/system/system-build.timer - DO NOT INCLUDE THIS LINE
[Unit]
Description=Daily automatic builds for custom bootc image

[Timer]
OnCalendar=daily
OnBootSec=15min

[Install]
WantedBy=timers.target

Now, run the following command so systemd finds these files.

sudo systemctl daemon-reload

You can build and switch to the image by running

sudo systemctl start build system-build.service

You can watch the status of the build by running

sudo systemctl status system-build.service

And enable the timer for it by running

sudo systemctl enable --now system-build.timer

For my convenience, I store these 3 files in the same directory in my home. I then use this script to copy them to their proper places (works on a new installs and updates existing installs).

#!/bin/bash

# bash safety options
# -e exits on failure
# -u exits on unknown variables
# -o pipefail exits on failed pipe
set -euox pipefail

# Parse arguments
RUN_NOW=false
for arg in "$@"; do
    case $arg in
        --now)
            RUN_NOW=true
            shift
            ;;
    esac
done

# regardless pwd when running, cd into this script's directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# move Containerfile to its destination
sudo mkdir -p /etc/system-image
sudo cp "$SCRIPT_DIR/Containerfile" /etc/system-image/
sudo cp -r "$SCRIPT_DIR/scripts" /etc/system-image/

# move system.build to its destination
sudo mkdir -p /etc/containers/systemd
sudo cp "$SCRIPT_DIR/system.build" /etc/containers/systemd/

# move system-build.timer to its destination
sudo mkdir -p /etc/systemd/system
sudo cp "$SCRIPT_DIR/system-build.timer" /etc/systemd/system/

# run daemon-reload so systemd creates system-build.service
sudo systemctl daemon-reload

# enable and start the timer schedule
sudo systemctl enable --now system-build.timer

# conditionally start the service and tail logs
if [ "$RUN_NOW" = true ]; then
    # trigger the build service to run immediately
    sudo systemctl start system-build.service --no-block

    # display the logs (control + C to exit)
    journalctl -u system-build.service -f
fi

The script has two modes:

  • ./update which just copies the updated files to their system locations and makes systemd aware of the changes. This waits until the next scheduled run of the build timer.
  • ./update --now which copies the files and builds the image immediately.

Thanks for the tutorial!

Just a couple of things I discovered along the way::

1. We can use these options to reuse rpm cache across rebuilds.

RUN --mount=type=cache,target=/var/cache/libdnf5 <<EOT
dnf -y --setopt=install_weak_deps=False --setopt=keepcache=True makecache --refresh
dnf -y --setopt=install_weak_deps=False --setopt=keepcache=True install …
EOT

That detail was already in the post; I just missed it.

  1. dnf can also install an RPM directly from a url.
dnf -y https://update.code.visualstudio.com/latest/linux-rpm-x64/stable \
       https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm \
...

I’ve also been using this and have made a bunch of changes. I published it to my GitHub: GitHub - novafunc/fedora-bootc-public · GitHub

Some notable things:

  • Mounts a resources folder to /tmp/resources to make things available, such as RPMs files to install or configuration files
  • A few different scripts. I have ./scripts/silverblue.sh, ./scripts/cosmic.sh, ./scripts/kinoite.sh for desktop specific modifications and ./scripts/common.sh for things I want regardless of which desktop I use.
  • ./scripts/common.sh also has functions to automate some tasks like: installing ghostty terminal, installing dependencies for homebrew, installing tailscale, adding ssh configurations, and timers for automatically upgrading the OS and flatpaks.
  • It also correctly caches RPMs (I think the orignal post has that being broken if IIRC?). It then deletes RPMs that are over 30 days old to avoid using too much disk space.
  • Leaves less remnants if you switch back to upstream Fedora Atomic. Everything is stored inside the OCI image rather than copied to mutable locations like /etc and /var.