Reproducible, immutable Rocky Linux 10 bootable container images built entirely from code.
Uses the upstream rocky-bootc build system from the Rocky Linux SIG/Containers group, with a customization layer for security hardening.
Traditional Linux installs from an ISO, then accumulates years of package updates, config edits, and drift. Every machine becomes a unique snowflake.
bootc inverts this. Your entire OS -- kernel, bootloader, packages, config -- is a standard OCI container image. You build it once with a Containerfile, push it to a registry, and every machine pulls the identical image. Updates replace the entire OS atomically on reboot. If something breaks, rollback is one command.
YOU (laptop/CI) REGISTRY BARE METAL
ββββββββββββββ ββββββββ ββββββββββ
1. Edit Containerfile
2. make custom βββββββββββββββββββ
3. make push βββββββββββββββββββββΌβββ ghcr.io/you/rocky-bootc:10
β
β βββββββββββββββββββββββββ
β β OCI container image β
β β (kernel + bootloader β
β β + rootfs + config) β
β βββββββββββββ¬ββββββββββββ
β β
β βΌ
β 5. bootc upgrade
β (pulls new image,
β stages for next boot)
β β
β βΌ
β 6. reboot
β (boots into new image,
β old image kept for
β rollback)
β
7. Repeat from step 1 β
Yes, this does exactly what you described: you change code, your CI builds a new image, pushes it to a registry, and the bare metal machine pulls it and boots into it on the next reboot.
bootc uses an A/B deployment scheme (backed by ostree):
- The running system is deployment A
bootc upgradedownloads the new image and stages it as deployment B- On reboot, the bootloader switches to deployment B
- Deployment A is kept as the rollback target
- If deployment B fails, run
bootc rollbackand reboot -- you're back on A
The root filesystem (/) is read-only by default. Only /etc (config) and /var (data) are writable and persist across updates. Everything else comes from the container image and is replaced atomically on update.
/ β Read-only, from the container image (via composefs/ostree)
βββ usr/ β Immutable. All OS binaries, libraries, systemd units.
βββ etc/ β Writable, persistent. Machine-local config.
β 3-way merged on updates (your edits are preserved).
βββ var/ β Writable, persistent. Data, logs, containers, databases.
β NOT replaced on updates (like a Docker VOLUME).
βββ sysroot/ β Physical root filesystem. Contains ostree deployments.
β βββ .bootc-aleph.json β Install provenance (original image, timestamp)
βββ boot/ β Bootloader, kernel, initramfs.
βββ tmp/ β tmpfs
Key implications:
- Packages you install via
dnfat runtime do not persist across updates. Install them in the Containerfile. - Config files in
/etcare 3-way merged: if you edit/etc/foo.conflocally AND the image ships a new version, the merge keeps your changes. Use drop-in directories (e.g./etc/ssh/sshd_config.d/) to avoid conflicts. - Data in
/varis never replaced by updates. A new image that changes/var/lib/somethingin the Containerfile will NOT affect the running system's/var.
There are several paths to get the initial install onto a physical machine:
Build an installer ISO that writes your image directly to disk:
make disk-iso
# Burns to USB:
sudo dd if=output/anaconda-iso/install.iso of=/dev/sdX bs=4M status=progressBoot the machine from the USB. The Anaconda installer writes your exact image to disk, configured per config.toml.
If you can boot any Linux on the machine (even a live USB), you can install directly from the container image:
# Pull and run your image with privileges, targeting the disk
sudo podman run --rm --privileged --pid=host \
-v /var/lib/containers:/var/lib/containers \
-v /dev:/dev \
--security-opt label=type:unconfined_t \
ghcr.io/antonym/rocky-bootc:10 \
bootc install to-disk /dev/sdaThis writes the partitions, bootloader, and OS image directly. Reboot and you're running your image.
If the machine already runs Linux (any distro), you can convert it in-place:
sudo podman run --rm --privileged --pid=host \
-v /var/lib/containers:/var/lib/containers \
-v /dev:/dev \
-v /:/target \
--security-opt label=type:unconfined_t \
ghcr.io/antonym/rocky-bootc:10 \
bootc install to-existing-rootThis reuses the existing filesystem and partitions but replaces the OS. /boot is reinitialized. Data in /var from the old system is preserved at /sysroot after reboot.
make disk # qcow2 for qemu/KVM/libvirt
make disk-raw # raw image, can dd to physical diskYou can network-boot machines and install your bootc image without any USB sticks or ISOs at all. This works by PXE-booting the Anaconda installer, which then pulls your container image from the registry over the network.
When you build an anaconda-iso with bootc-image-builder (make disk-iso), the ISO contains:
ISO contents:
βββ vmlinuz β Generic Rocky/Fedora installer kernel
βββ initrd.img β Generic initrd that starts Anaconda
βββ install.img β Anaconda installer (squashfs)
βββ osbuild.ks β Kickstart file (THIS is the key)
The kernel and initrd know nothing about bootc or your image. They just boot Anaconda. The magic is in the kickstart file, which contains an ostreecontainer command:
ostreecontainer --url=ghcr.io/antonym/rocky-bootc:10 --transport=registry --no-signature-verification
ostreecontainer is a kickstart command (added in Fedora 38 / RHEL 9.2) that tells Anaconda: "instead of installing RPM packages, pull this OCI container image from a registry and deploy it as the OS using ostree." At install time, Anaconda:
- Partitions the disk (per the kickstart)
- Pulls the container image from the registry
- Deploys it via
bootc install to-filesystemunder the hood - Installs the bootloader
- Reboots into your image
The ostreecontainer command options:
| Option | Description |
|---|---|
--url |
Container image reference (e.g. ghcr.io/antonym/rocky-bootc:10) |
--transport |
How to fetch: registry (default), oci, or oci-archive |
--no-signature-verification |
Skip ostree signature checks (needed for unsigned images) |
--stateroot |
Name for the ostree state directory (default: default) |
--remote |
Name of the ostree remote |
After installation, bootc upgrade will pull from the same --url for future updates.
Since the installer kernel/initrd are generic, you can extract them from the ISO and serve them via TFTP for PXE boot. The kickstart file gets served via HTTP.
1. Build the ISO (to get the kernel/initrd):
make disk-iso2. Extract PXE boot files from the ISO:
mkdir -p pxe/
# Mount the ISO and copy the boot files
sudo mount -o loop output/bootiso/install.iso /mnt
cp /mnt/images/pxeboot/vmlinuz pxe/
cp /mnt/images/pxeboot/initrd.img pxe/
sudo umount /mnt3. Create a kickstart file (pxe/kickstart.ks):
# Automated bootc install via PXE
text --non-interactive
zerombr
clearpart --all --initlabel --disklabel=gpt
autopart --noswap --type=lvm
# Network - DHCP on first link
network --bootproto=dhcp --device=link --activate --onboot=on
# Pull and deploy the bootc container image from your registry
ostreecontainer --url=ghcr.io/antonym/rocky-bootc:10 --transport=registry --no-signature-verification
# Users (if not baked into the image)
# rootpw --plaintext changeme
# sshkey --username=admin "ssh-ed25519 AAAA..."
# Reboot after install
reboot4. Serve via TFTP + HTTP:
Set up your PXE infrastructure (dnsmasq is the simplest):
# Example dnsmasq.conf for PXE boot
# Adjust interface/IP range for your network
interface=eth0
dhcp-range=192.168.1.100,192.168.1.200,24h
dhcp-boot=pxelinux.0
enable-tftp
tftp-root=/srv/tftp# TFTP directory structure
/srv/tftp/
βββ pxelinux.0 β BIOS bootloader (from syslinux package)
βββ ldlinux.c32
βββ pxelinux.cfg/
β βββ default β PXE boot menu
βββ vmlinuz β from step 2
βββ initrd.img β from step 2
# For UEFI, use grubx64.efi or shimx64.efi instead of pxelinux.0PXE boot menu (/srv/tftp/pxelinux.cfg/default):
DEFAULT rocky-bootc
LABEL rocky-bootc
KERNEL vmlinuz
APPEND initrd=initrd.img inst.ks=http://192.168.1.1/kickstart.ks
5. Serve the kickstart over HTTP:
# Simple approach: python HTTP server
cd pxe/
python3 -m http.server 80Or put kickstart.ks in your existing HTTP server's docroot.
Machine powers on
β BIOS/UEFI does PXE/DHCP
β TFTP loads vmlinuz + initrd.img
β Kernel boots, initrd starts Anaconda
β Anaconda fetches kickstart from HTTP (inst.ks=...)
β Kickstart says: ostreecontainer --url=ghcr.io/.../rocky-bootc:10
β Anaconda pulls the OCI image from ghcr.io
β Writes it to disk via bootc
β Reboots into your immutable Rocky image
No USB sticks, no ISOs on disk. Just power on, PXE boot, and the machine provisions itself from the registry. Every machine gets the exact same image.
If your machines can't reach a registry during install, use --type bootc-installer instead of --type anaconda-iso. This embeds the entire container image inside the ISO itself -- bigger ISO, but fully air-gapped. The PXE flow is the same (extract kernel/initrd, serve via TFTP), but the kickstart uses --transport=oci-archive pointing at the embedded image.
Once installed, the machine updates itself by pulling new container images from your registry.
SSH into the machine:
# Check what's running vs what's available
bootc status
# Pull the latest image and stage it for next boot
bootc upgrade
# Reboot into the new image
systemctl rebootOr in one step:
bootc upgrade --apply # pulls, stages, and reboots immediatelyThe base image ships with bootc-fetch-apply-updates.timer which can be enabled for automatic updates:
systemctl enable --now bootc-fetch-apply-updates.timerThis periodically checks for new images and stages them. On the next reboot (whether manual or scheduled), the new image takes effect.
For a CI-driven workflow, your pipeline would:
- Build and push a new image to the registry
- SSH into machines and run
bootc upgrade --apply, or - Let the timer pick it up automatically
For production, you may want to separate download from activation:
# Download but don't activate yet
bootc upgrade --download-only
# Check what was staged
bootc status --verbose
# Shows: Download-only: yes
# Later, during maintenance window:
bootc upgrade --from-downloaded --apply # activates and rebootsYou can also point a machine at a completely different image:
# Switch from one image to another (e.g. blue/green deployment)
bootc switch ghcr.io/antonym/rocky-bootc:10-canaryThis preserves all state in /etc and /var (SSH keys, home dirs, data).
If an update breaks something:
bootc rollback
systemctl rebootThis boots back into the previous deployment. The broken image stays staged so you can investigate.
The root filesystem (/) is read-only. So where does mutable data go?
| Path | Behavior | Survives updates? | Use for |
|---|---|---|---|
/var/ |
Writable, persistent | Yes -- never touched by updates | Databases, app data, logs, containers, home dirs |
/etc/ |
Writable, persistent | Yes -- 3-way merged on updates | Machine-local config (SSH host keys, network, hostname) |
/usr/, /opt/, everything else |
Read-only | Replaced entirely on update | OS binaries, libraries, default configs |
/tmp/ |
tmpfs | No (cleared on reboot) | Temporary files |
/var works like a Docker VOLUME -- it's initialized once from the image and never overwritten by updates. This is where all mutable state lives:
/var/
βββ lib/ β Application state (databases, containers, etc.)
β βββ postgres/
β βββ containers/
β βββ mysql/
βββ log/ β System and application logs
βββ home/ β User home directories (if symlinked or mounted here)
βββ data/ β Your custom data directory
βββ usrlocal/ β /usr/local symlinks here (for locally-installed binaries)
Say you run PostgreSQL. The database files live in /var/lib/pgsql/data -- that's under /var, so they persist across updates automatically. You just need to make sure the directory exists:
In your Containerfile:
RUN dnf install -y postgresql-server && dnf clean all
RUN systemctl enable postgresql
# Ensure the data directory structure exists on first boot.
# systemd-tmpfiles creates directories listed here if they don't exist.
COPY config/tmpfiles.d/postgresql.conf /usr/lib/tmpfiles.d/postgresql.confconfig/tmpfiles.d/postgresql.conf:
d /var/lib/pgsql 0750 postgres postgres -
d /var/lib/pgsql/data 0700 postgres postgres -
The systemd-tmpfiles approach is the recommended bootc pattern -- it declaratively ensures directories exist at boot without relying on /var content from the container image.
For your own application data at /var/data:
In your Containerfile:
# Create a tmpfiles rule so /var/data is created on first boot
RUN echo 'd /var/data 0755 root root -' > /usr/lib/tmpfiles.d/custom-data.confIn config.toml (for a dedicated partition):
[[customizations.filesystem]]
mountpoint = "/var/data"
minsize = "50 GiB"If you want home directories on their own partition:
config.toml:
[[customizations.filesystem]]
mountpoint = "/var/home"
minsize = "20 GiB"Note: some images symlink /home β /var/home. If yours doesn't, add this to the Containerfile:
RUN rm -rf /home && ln -s /var/home /homeSome third-party software expects to write to /opt. Since /opt is read-only on a bootc system, you have a few options:
Option 1: Symlink the writable parts to /var (recommended)
RUN dnf install -y /path/to/somepkg.rpm && dnf clean all
# Move mutable directories under /var, symlink back
RUN mv /opt/somepkg/data /var/lib/somepkg-data && \
ln -s /var/lib/somepkg-data /opt/somepkg/data && \
mv /opt/somepkg/logs /var/log/somepkg && \
ln -s /var/log/somepkg /opt/somepkg/logsOption 2: Use a systemd bind mount
# In a systemd unit override for the service
[Service]
BindPaths=/var/lib/somepkg-data:/opt/somepkg/dataOption 3: Enable a state overlay (entire /opt writable, persists across reboots)
RUN systemctl enable ostree-state-overlay@opt.service/etc is writable and persistent, but handled differently from /var:
- On update, bootc does a 3-way merge: it compares the old image's
/etc, the new image's/etc, and your local/etc. Your local changes are preserved unless they conflict with a new default. - Best practice: use drop-in directories (
/etc/ssh/sshd_config.d/,/etc/sysctl.d/,/etc/sudoers.d/) instead of editing main config files. Drop-ins never conflict. - Machine-local state like SSH host keys, hostname, and network config live here and survive updates.
- If you want
/etcto be fully ephemeral (regenerated from the image on every boot), you can enable transient/etcin the image:
# In Containerfile -- makes /etc a tmpfs overlay, reset on every reboot
RUN mkdir -p /usr/lib/ostree && \
printf '[etc]\ntransient = true\n' >> /usr/lib/ostree/prepare-root.confThis is more extreme but eliminates all config drift -- every reboot starts with exactly what's in the image.
| What | Where | Why |
|---|---|---|
| OS packages | Containerfile RUN dnf install |
Immutable, versioned with image |
| Default config | Containerfile COPY into /etc/*.d/ or /usr/lib/ |
Travels with image, drop-ins merge cleanly |
| Machine-local config | /etc/ (modified at runtime) |
3-way merged on updates |
| Database files | /var/lib/<app>/ |
Persistent, not touched by updates |
| Application data | /var/data/ or similar |
Persistent, optionally on own partition |
| User home dirs | /var/home/ |
Persistent |
| Logs | /var/log/ |
Persistent |
| Container images | /var/lib/containers/ |
Persistent |
| Temporary files | /tmp/ |
Ephemeral, cleared on reboot |
| Secrets/creds | /etc/ or inject at boot via cloud-init |
Machine-local |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β upstream/Containerfile β
β Builds Rocky bootc base from scratch using upstream β
β rocky-bootc (rpm-ostree compose from RPM packages) β
β β localhost/rocky-bootc-base:10 β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β FROM
ββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββ
β Containerfile β
β Your customizations: packages, configs, services β
β SSH hardening, firewalld, sysctl, audit, crypto policy β
β β localhost/rocky-bootc:10 β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β
ββββββββββββββββΌβββββββββββββββ
βΌ βΌ βΌ
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β make disk β β make disk-isoβ β make push β
β β qcow2 β β β install ISOβ β β registry β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
The make base target runs the upstream rocky-bootc build, which is unusual -- it's a Containerfile that uses nested containerization to compose an OS:
- Builder stage: Starts from
rockylinux/rockylinux:10, installsrpm-ostree - Compose: Runs
rpm-ostree compose rootfswith YAML manifests that define the package set (kernel, systemd, bootc, dnf, SELinux, networking, etc.) - OCI archive: Converts the composed rootfs into an OCI archive via
rpm-ostree experimental compose build-chunked-oci - Final stage:
FROM oci-archive:./out.ociarchiveimports the composed rootfs as a standard container image, adds labels and bootc metadata
The YAML manifests (in upstream/ and upstream/fedora-bootc/) define the package sets:
minimal/: Kernel, systemd, bootc, bootupd, ostree, initramfs, SELinux -- bare minimum to bootstandard/: Adds networking tools, auto-updates, growpart, persistent journal, system config toolsincludes/rocky.yaml: Rocky-specific:dnf-yum,dnf-bootc, XFS as default root filesystem, systemd preset-all
The make custom target is a standard podman build -- just a Containerfile that layers on top of the base:
FROM localhost/rocky-bootc-base:10
RUN dnf install -y <packages> && dnf clean all
COPY config/ files into /etc/
RUN systemctl enable <services>
This is the file you edit to customize your OS. It works exactly like building any container image.
The make disk target runs bootc-image-builder, which takes your OCI container image and produces a bootable disk. It:
- Reads your container image from podman storage
- Reads
config.tomlfor user accounts, SSH keys, disk layout - Creates partition table (ESP + root + any custom partitions)
- Writes the OS image to the root partition via ostree
- Installs the bootloader (GRUB/systemd-boot via bootupd)
- Outputs the disk image in the requested format
- Linux host with Podman and fuse support (for base image build)
- sudo for disk image builds (bootc-image-builder needs privileged access)
The base image build requires rpm-ostree compose which needs /dev/fuse and privileged containers. This runs natively on Linux or via CI (GitHub Actions).
# Clone with submodules
git clone --recursive https://github.com/antonym/bootc-rocky.git
cd bootc-rocky
# IMPORTANT: Edit config.toml and set your SSH public key
# Build everything: base image β custom image β qcow2 disk
make all| Target | Description |
|---|---|
make base |
Build base image from scratch (requires Linux + fuse) |
make base-minimal |
Build minimal base variant |
make custom |
Build hardened custom image (from base) |
make disk |
Build qcow2 disk image |
make disk-iso |
Build installer ISO |
make disk-raw |
Build raw disk image |
make push |
Push custom image to ghcr.io |
make push-base |
Push base image to ghcr.io |
make clean |
Remove build artifacts |
make info |
Show current build configuration |
Override on the command line:
make custom BASE_IMAGE=ghcr.io/antonym/rocky-bootc-base:10
make push REGISTRY=my-registry.example.com/myorg
make base MANIFEST=minimal| Variable | Default | Description |
|---|---|---|
BASE_IMAGE |
localhost/rocky-bootc-base:10 |
Base image for derived builds |
REGISTRY |
ghcr.io/antonym |
Container registry for push |
IMAGE_NAME |
rocky-bootc |
Image name |
IMAGE_TAG |
10 |
Image tag |
MANIFEST |
standard |
Upstream manifest: standard or minimal |
DISK_TYPE |
qcow2 |
Disk format: qcow2, raw, vmdk, ami, anaconda-iso |
Edit Containerfile and add packages to the dnf install blocks:
# Add your workload packages
RUN dnf install -y \
nginx \
postgresql-server \
&& dnf clean all
RUN systemctl enable nginx postgresqlDrop files into config/ and COPY them in the Containerfile:
COPY config/nginx/my-site.conf /etc/nginx/conf.d/my-site.confUse /etc/<app>.d/ drop-in directories whenever possible. These survive the 3-way merge on updates cleanly.
Edit config.toml:
[[customizations.filesystem]]
mountpoint = "/"
minsize = "20 GiB"
[[customizations.filesystem]]
mountpoint = "/var/data"
minsize = "50 GiB"Edit config.toml:
[[customizations.user]]
name = "admin"
groups = ["wheel"]
key = "ssh-ed25519 AAAA... you@host"The default Containerfile ships a hardened baseline:
| Area | Configuration |
|---|---|
| SSH | Key-only auth, no root login, modern ciphers (Ed25519/ChaCha20), max 3 auth tries |
| Firewall | firewalld enabled, SSH-only public zone |
| Kernel | Hardened sysctl: no IP forwarding, SYN cookies, restricted ptrace/dmesg/kptr, BPF hardening |
| Crypto | FUTURE policy (strongest algorithms only, disables SHA-1, short keys, etc.) |
| Audit | auditd enabled |
| Core dumps | Disabled via systemd coredump config |
| SUID/SGID | Unnecessary bits removed from newgrp, chfn, chsh |
| Immutable root | Read-only / filesystem by default (bootc/composefs) |
| Umask | Set to 027 (no world-readable new files) |
The GitHub Actions workflow (.github/workflows/build.yml) runs on every push to main:
- Builds the base Rocky bootc image from scratch (with submodules)
- Builds the custom hardened image on top
- Pushes both to
ghcr.io/antonym/rocky-bootc:10andghcr.io/antonym/rocky-bootc-base:10 - Optionally builds disk image artifacts (manual trigger via workflow dispatch)
The intended production workflow:
git push (Containerfile change)
β GitHub Actions builds new image
β Pushes to ghcr.io/antonym/rocky-bootc:10
β Bare metal machines either:
a) Auto-update via bootc-fetch-apply-updates.timer, or
b) You SSH in and run: bootc upgrade --apply
β Machine reboots into new OS
To pull in new Rocky bootc upstream changes (security fixes, package updates, etc.):
cd upstream
git fetch origin
git pull origin r10
cd ..
git add upstream
git commit -m "Update upstream rocky-bootc"
git pushThis triggers a CI build with the latest upstream, producing a new image.
bootc-rocky/
βββ Containerfile # YOUR custom image definition (edit this)
βββ Makefile # Build targets
βββ config.toml # Disk image config: users, SSH keys, partitions
βββ config/ # OS configuration files baked into the image
β βββ sshd/99-hardening.conf
β βββ sysctl.d/99-hardening.conf
β βββ firewalld/public.xml
β βββ systemd/coredump.conf.d/disable.conf
βββ .github/workflows/build.yml # CI pipeline
βββ upstream/ # git submodule β rocky-bootc r10
β βββ Containerfile # Upstream base image build
β βββ build.sh # Upstream build orchestration
β βββ standard.yaml # Full package manifest
β βββ minimal.yaml # Minimal package manifest
β βββ includes/rocky.yaml # Rocky-specific config (XFS, dnf-bootc)
β βββ fedora-bootc/ # Nested submodule β fedora bootc build tools
β βββ bootc-base-imagectl # Python build tool
β βββ minimal/ # Minimal manifest (kernel, systemd, bootc)
β βββ standard/ # Standard manifest (networking, updates)
βββ output/ # Build artifacts (gitignored)
| Component | Repository | Role |
|---|---|---|
| Rocky bootc image definitions | git.resf.org/sig_containers/rocky-bootc (branch: r10) | Package manifests and Rocky-specific config |
| Fedora bootc base images | gitlab.com/fedora/bootc/base-images | Shared build machinery (bootc-base-imagectl, manifest system) |
| bootc | github.com/bootc-dev/bootc | Client tool on deployed systems (bootc upgrade, bootc switch, bootc rollback) |
| bootc-image-builder | github.com/osbuild/bootc-image-builder | Converts OCI images to disk images (qcow2, ISO, AMI, etc.) |
| bootc documentation | bootc-dev.github.io/bootc | Comprehensive docs on filesystem, updates, installation |
| Fedora bootc docs | docs.fedoraproject.org/en-US/bootc/ | Distribution-level bootc documentation |
Build system and customizations: MIT. Upstream components carry their own licenses (see upstream/LICENSE).