Chainguard makes really nice container images. They’re minimal, they’re kept up to date, and there’s a real product behind them. They now sell the same idea for hosts in VM image form too.
This post is about reproducing most of that for free. We’ll go over how to setup a Wolfi-based build pipeline that produces clean images from packages that stay up to date. Finally we’ll extend the pipeline to create bootable container host images with bootc.
I’m not affiliated with Chainguard; I’m borrowing the good ideas and sharing how I’m building images for my homelab using the community repositories.
Here’s what the final result looks like. ghcr.io/vaskozl/bootc is a public bootc capable base image. It’s built to be used as a starting point, then extended with the bits you actually want on your machine.
vaskozl/wolfi-bootc is an example of using it. The Dockerfile is intentionally boring:
FROM ghcr.io/vaskozl/bootc:1.11.0
RUN apk add --no-cache \
systemd-default-networksystemd-default-network drops in a default systemd-networkd config that brings up common interfaces. The default bootc image contains a nori-user package that sets nori as the username and password, which makes first boot less annoying. Once changed the password persists.
If you want a fully headless install, add SSH packages so you can log in immediately after the first boot:
RUN apk add --no-cache openssh openssh-server-config openssh-serviceYou can, if you wish, COPY arbitrary unpackaged files into the image too.
The base image is published for both amd64 and arm64. You can extend it via a Dockerfile as shown above, but keep reading for how to extend it in a purely declarative fashion with apko.
If you just want to “flash” a bootable image quickly, you can do it with podman:
fallocate -l 20G bootable.img
podman run --rm --privileged --pid=host -it \
-v /sys/fs/selinux:/sys/fs/selinux:Z \
-v /etc/containers:/etc/containers \
-v /var/lib/containers:/var/lib/containers \
-v /dev:/dev \
-v "$PWD":/data \
--security-opt label=type:unconfined_t \
ghcr.io/vaskozl/bootc:1.11.0 \
bootc install to-disk --composefs-backend --via-loopback /data/bootable.img --filesystem ext4 --wipe --bootloader systemdIf you want a couple of examples of what you can do with the base ghcr.io/vaskozl/bootc image, I have a few which might peak your interest:
-
A full wayland based graphical desktop-style host with Cagebreak, Foot, and Chromium, plus a bunch of quality-of-life packages. See
vaskozl/containers/cagebreak.yaml. Decidedely not an undistro at this point. -
A full home router image. The interesting part is that the configuration is packaged and versioned, so it fits the same update story as everything else. See
vaskozl/containers/pinewall-config.yaml.
Build your own images #
I keep the container side of this at vaskozl/containers. It’s mostly apko YAML and CI, with the goal of making “add a new image” cheap and declarative.
apko is the first idea to understand. You point it at a package repository, list the packages you want, and it composes an OCI image filesystem from those packages. Unlike with a Dockerfile, there are no RUN or COPY directives which allow you to invoke arbitrary ad hoc filesystem mutations. The end result is a fully clean image with a precise SBOM, file ownership, composability and layering efficiency.
If this sounds niche, it isn’t. Docker is now doing something very similar for Docker Hardened Images using a BuildKit frontend. They describe images declaratively in YAML, including package repos and package lists, and build and publish them from that. As of writing, the new frontend isn’t public yet, but the direction is clear. You can see an example here: docker-hardened-images/catalog Node.js 25 on Alpine 3.22.
The main reason you might want to use COPY is to install files or programs which are not packaged in the (un)distrubions repository. What do you do if the package you want doesn’t exist? That’s where melange comes in. If you’ve built .deb or .rpm packages before, the idea will feel familiar. You take source code, build it in a controlled environment (like a container), and produce a versioned package that declares dependencies and owns a well-defined set of files. Wolfi uses Apline’s APK package package format and melange help produce them.
I like packages as the unit of composition. It might seems like unecessary work, but if a file comes from a package, it’s attributable. apk info -L <pkg> tells you what a package installed. You get versions you can pin and audit, and you can split things cleanly. Runtime images can install only the runtime package, while build environments can pull in -dev, -doc, or -static subpackages without polluting the final image. It’s hard to get that kind of structure if your build process is mostly ad hoc COPY and RUN curl .... Finally once your package is built, you are able to quickly rebuild the final container image without recompiling anything on top.
The update signal #
If you go down the declarative route, you end up pinning package versions in YAML (apko image config, melange package configs). Pinning is good. The real problem is the signal: knowing that something changed upstream, and turning that into a rebuild of the right images at the right time.
Without that signal you either rebuild constantly, or you rebuild only when you remember, which is how “out of date” happens.
Renovate is great at this, but it doesn’t natively know how to ask an APK repository “what’s the latest version of package X?”.
That’s where renovate-apk-indexer comes in. It’s a small HTTP server that reads one or more APKINDEX.tar.gz files and answers Renovate custom datasource queries. The upstream project is hown3d/renovate-apk-indexer. I package it and publish my own image too, so we can maintain the “everything is packaged” model.
The apko config for my image is here: vaskozl/containers/renovate-apk-indexer.yaml. It’s a good small example of what an apko image definition looks like.
In GitLab CI you run it as a service next to Renovate:
renovate:
image: ghcr.io/renovatebot/renovate:latest
services:
- name: ghcr.io/vaskozl/renovate-apk-indexer:latest
alias: apk-wolfi
script:
- renovateThen wire a custom datasource in renovate.json:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"customDatasources": {
"wolfi-base": {
"defaultRegistryUrlTemplate": "http://apk-wolfi:3000/{{packageName}}"
}
}
}Once this exists, Renovate can open merge requests that bump pinned package versions and trigger rebuilds. I actually run renovate-apk-indexer in kubernetes as proper daemon, but you can run it however you like.
The nice part is that we get to track our custom repository in additional to Wolfi repositories. Renovate can bump all apko definitations using a package as soon as it sees a new version is available. Because we control pin the main package versions we can readily tag our containers based on the package version.
What we’re building #
The goal is a bootable container. That means an OCI image that contains bootc, systemd, a kernel plus initramfs, and the OSTree/ComposeFS stack that bootc uses. Wolfi started as an “undistro” during the distroless movement, so if you want to boot it you need to supply the host pieces yourself.
At the end, you can treat your OS like an app artifact: upgrade atomatically, pin by digest, roll it back, and rebuild it whenever the are changes.
This is also where I think the bootc approach can be better than Chainguard’s current VM offering. A raw VM image or AMI is a separate artifact type with a separate release channel. With bootc the host is delivered as an OCI artifact, so it can follow the same registry distribution and promotion flow as your applications. Once the machine is installed, updates are as simple as running bootc update, without any dependency on cloud AMI pipelines or VM image management.
The missing packages #
To make bootc work end-to-end on Wolfi, I had to add the missing “host bits” as packages. They’re all built with melange, but they come from different upstreams and need a bit of glue. A lot of the early bootc-on-Wolfi work was already done by the Bluefin folks in projectbluefin/wolfifin. My packages are updated versions of that work to fit my repos and build pipeline.
The short version is: kernel, initramfs, bootc, and the deployment stack.
kernel is repackaged from Alpine’s linux-stable APK, because I wanted a known-good kernel payload quickly. dracut builds the initramfs, and kernel-initramfs is the glue package that runs depmod and produces initramfs.img during the build. ostree plus composefs are what make bootc’s deployment and filesystem layout work. bootc itself is built from bootc-dev/bootc and expects a host-shaped filesystem, so bootc-baselayout exists mostly to ensure the base directories (/sysroot, /boot) are present.
How it fits together #
I keep this split into two repos (or two logical stages). One produces packages, the other produces images (both the traditional and bootable kind).
First, I build the missing host packages with melange and publish them as APK repositories (one per architecture). Melange produces the APKINDEX.tar.gz for you, so the main operational job is just serving the repositories somewhere.
Second, I build images with apko. For ordinary application images, this looks like any other apko YAML that lists packages. For the host image, it becomes a bootable container image.
Finally, Renovate glues it together. It provides the rebuild signal by proposing version bumps when the repo contents change, and once merged, CI rebuilds the images.
My images are defined as apko YAML files with pinned package versions, for example:
contents:
packages:
- bootc=1.11.0-r0
- systemd
- ostreeSo I use a Renovate regex custom manager to update apko YAML. It extracts pkg=version lines and checks them against the APK index service.
Build the bootable image with apko #
The bootable container itself is just an apko config that installs the pieces you need.
Here’s a trimmed example of what matters:
include: wolfi-base.yaml
contents:
packages:
- bootc=1.11.0-r0
- ostree
- composefs
- systemd
- systemd-boot
- kernel
- kernel-initramfs
cmd: init
stop-signal: SIGRTMIN+3
accounts:
run-as: root
annotations:
containers.bootable: "1"That last annotation is the key: it marks the image as a “bootable container” artifact.
Install and update with bootc #
Once you have an image in a registry, the host lifecycle becomes image-driven.
Install a machine from an image, upgrade by pulling a newer image (or switching to another reference), and roll back by going back to the previous deployment.
Exactly how you install depends on your environment (bare metal vs VM, partitioning, secure boot, etc), but the important part is that your OS is now “a container image you can boot”.
Check out github.com/vaskozl/wolfi-bootc to get started quickly.
References #
renovate-apk-indexer: hown3d/renovate-apk-indexer
Containers repo: vaskozl/containers
My renovate-apk-indexer image config: vaskozl/containers/renovate-apk-indexer.yaml
Example usage of ghcr.io/vaskozl/bootc: vaskozl/wolfi-bootc
Bluefin Wolfi bootc work: projectbluefin/wolfifin