Skip to content

antonym/bootc-rocky

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

1 Commit
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

bootc-rocky

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.

How it works

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.

The lifecycle

 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.

What happens on the machine

bootc uses an A/B deployment scheme (backed by ostree):

  • The running system is deployment A
  • bootc upgrade downloads 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 rollback and 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.

Filesystem layout on a running system

/                     ← 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 dnf at runtime do not persist across updates. Install them in the Containerfile.
  • Config files in /etc are 3-way merged: if you edit /etc/foo.conf locally 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 /var is never replaced by updates. A new image that changes /var/lib/something in the Containerfile will NOT affect the running system's /var.

Getting your image onto bare metal

There are several paths to get the initial install onto a physical machine:

Option A: Install ISO (recommended for bare metal)

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=progress

Boot the machine from the USB. The Anaconda installer writes your exact image to disk, configured per config.toml.

Option B: bootc install to-disk (from a running container)

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/sda

This writes the partitions, bootloader, and OS image directly. Reboot and you're running your image.

Option C: bootc install to-existing-root (convert an existing system)

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-root

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

Option D: QCOW2/raw image (for VMs or dd to disk)

make disk          # qcow2 for qemu/KVM/libvirt
make disk-raw      # raw image, can dd to physical disk

Option E: PXE / network boot

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

How the Anaconda ISO install actually works

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:

  1. Partitions the disk (per the kickstart)
  2. Pulls the container image from the registry
  3. Deploys it via bootc install to-filesystem under the hood
  4. Installs the bootloader
  5. 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.

Setting up PXE boot

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-iso

2. 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 /mnt

3. 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
reboot

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

PXE 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 80

Or put kickstart.ks in your existing HTTP server's docroot.

PXE boot flow

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.

Alternative: bootc-installer ISO (offline/embedded)

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.

Updating a running system

Once installed, the machine updates itself by pulling new container images from your registry.

Manual update

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 reboot

Or in one step:

bootc upgrade --apply    # pulls, stages, and reboots immediately

Automated updates

The base image ships with bootc-fetch-apply-updates.timer which can be enabled for automatic updates:

systemctl enable --now bootc-fetch-apply-updates.timer

This 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:

  1. Build and push a new image to the registry
  2. SSH into machines and run bootc upgrade --apply, or
  3. Let the timer pick it up automatically

Controlled rollout with download-only

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 reboots

Switching images entirely

You 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-canary

This preserves all state in /etc and /var (SSH keys, home dirs, data).

Rollback

If an update breaks something:

bootc rollback
systemctl reboot

This boots back into the previous deployment. The broken image stays staged so you can investigate.

Persistent and mutable data

The root filesystem (/) is read-only. So where does mutable data go?

The rules

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 is your mutable data root

/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)

Example: Persisting application data

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

config/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.

Example: Custom data directory

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

In config.toml (for a dedicated partition):

[[customizations.filesystem]]
mountpoint = "/var/data"
minsize = "50 GiB"

Example: Separate /var/home partition

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 /home

Example: Making /opt/somepkg writable

Some 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/logs

Option 2: Use a systemd bind mount

# In a systemd unit override for the service
[Service]
BindPaths=/var/lib/somepkg-data:/opt/somepkg/data

Option 3: Enable a state overlay (entire /opt writable, persists across reboots)

RUN systemctl enable ostree-state-overlay@opt.service

What about /etc?

/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 /etc to be fully ephemeral (regenerated from the image on every boot), you can enable transient /etc in 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.conf

This is more extreme but eliminates all config drift -- every reboot starts with exactly what's in the image.

Summary: where to put things

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

Build system

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  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   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

How the base image build works

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:

  1. Builder stage: Starts from rockylinux/rockylinux:10, installs rpm-ostree
  2. Compose: Runs rpm-ostree compose rootfs with YAML manifests that define the package set (kernel, systemd, bootc, dnf, SELinux, networking, etc.)
  3. OCI archive: Converts the composed rootfs into an OCI archive via rpm-ostree experimental compose build-chunked-oci
  4. Final stage: FROM oci-archive:./out.ociarchive imports 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 boot
  • standard/: Adds networking tools, auto-updates, growpart, persistent journal, system config tools
  • includes/rocky.yaml: Rocky-specific: dnf-yum, dnf-bootc, XFS as default root filesystem, systemd preset-all

How the custom image build works

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.

How the disk image build works

The make disk target runs bootc-image-builder, which takes your OCI container image and produces a bootable disk. It:

  1. Reads your container image from podman storage
  2. Reads config.toml for user accounts, SSH keys, disk layout
  3. Creates partition table (ESP + root + any custom partitions)
  4. Writes the OS image to the root partition via ostree
  5. Installs the bootloader (GRUB/systemd-boot via bootupd)
  6. Outputs the disk image in the requested format

Requirements

  • 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).

Quick start

# 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

Build targets

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

Variables

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

Customizing your image

Adding packages

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 postgresql

Adding configuration files

Drop files into config/ and COPY them in the Containerfile:

COPY config/nginx/my-site.conf /etc/nginx/conf.d/my-site.conf

Use /etc/<app>.d/ drop-in directories whenever possible. These survive the 3-way merge on updates cleanly.

Changing the disk layout

Edit config.toml:

[[customizations.filesystem]]
mountpoint = "/"
minsize = "20 GiB"

[[customizations.filesystem]]
mountpoint = "/var/data"
minsize = "50 GiB"

Setting up user access

Edit config.toml:

[[customizations.user]]
name = "admin"
groups = ["wheel"]
key = "ssh-ed25519 AAAA... you@host"

What's included (security hardening)

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)

CI/CD

The GitHub Actions workflow (.github/workflows/build.yml) runs on every push to main:

  1. Builds the base Rocky bootc image from scratch (with submodules)
  2. Builds the custom hardened image on top
  3. Pushes both to ghcr.io/antonym/rocky-bootc:10 and ghcr.io/antonym/rocky-bootc-base:10
  4. Optionally builds disk image artifacts (manual trigger via workflow dispatch)

End-to-end CI workflow

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

Updating upstream

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 push

This triggers a CI build with the latest upstream, producing a new image.

Repo structure

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)

Upstream references

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

License

Build system and customizations: MIT. Upstream components carry their own licenses (see upstream/LICENSE).

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors