Skip to content

Commit 2b2dd8f

Browse files
committed
blog: add sealed images building post
Third post in the sealed images series, covering the multi-stage container build process for producing a sealed, signed bootc image. References the rhel-bootc-examples repository for the complete working example. Assisted-by: OpenCode (claude-opus-4-6) Signed-off-by: John Eckersberg <jeckersb@redhat.com>
1 parent 8ef271d commit 2b2dd8f

1 file changed

Lines changed: 200 additions & 0 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
+++
2+
title = "Sealed images: building a sealed image"
3+
date = 2026-04-29
4+
slug = "2026-apr-29-sealed-images-building"
5+
6+
[extra]
7+
author = "jeckersb"
8+
+++
9+
10+
# Sealed images: building a sealed image
11+
12+
In the [first post](@/blog/2026-apr-27-sealed-images-security-chain.md)
13+
we covered the security chain behind sealed images, and in the
14+
[second post](@/blog/2026-apr-28-sealed-images-key-management.md) we
15+
generated the Secure Boot keys needed to sign our boot artifacts.
16+
Now it's time to put it all together and build a sealed image.
17+
18+
A complete working example is available in the
19+
[rhel-bootc-examples](https://github.com/redhat-cop/rhel-bootc-examples)
20+
repository under the
21+
[sealing](https://github.com/redhat-cop/rhel-bootc-examples/tree/main/sealing)
22+
directory. This post walks through the key concepts behind that
23+
example and explains why the build is structured the way it is.
24+
25+
## The chicken-and-egg problem
26+
27+
Building a sealed image has a complication that isn't immediately
28+
obvious. Recall from the first post that the composefs digest is
29+
embedded in the UKI's kernel command line, and the UKI lives in the
30+
image under `/boot`. So we have a dependency cycle: we need the
31+
filesystem to compute the composefs digest, but we need the digest
32+
to produce the UKI, and we need the UKI to finalize the filesystem.
33+
34+
The solution is a multi-stage container build. We build the base
35+
filesystem first, without any UKI. Then in a separate stage, we
36+
compute the composefs digest from that filesystem, generate and
37+
sign the UKI, and finally layer the UKI back on top of the base.
38+
The UKI lives under `/boot`, which is not part of the composefs-
39+
managed root filesystem, so adding it doesn't change the digest.
40+
41+
## The Containerfile
42+
43+
The
44+
[Containerfile](https://github.com/redhat-cop/rhel-bootc-examples/blob/main/sealing/Containerfile)
45+
in the examples repository uses four stages. Let's walk through
46+
each one.
47+
48+
### Stage 1: rootfs-builder
49+
50+
```dockerfile
51+
FROM quay.io/centos-bootc/centos-bootc:stream10 AS rootfs-builder
52+
53+
RUN dnf install -y \
54+
epel-release \
55+
systemd-boot-unsigned \
56+
systemd-ukify \
57+
sbsigntools
58+
59+
RUN dnf remove -y bootupd
60+
```
61+
62+
This starts from a CentOS Stream 10 bootc base image and installs
63+
the tooling we need: `systemd-ukify` to build the UKI,
64+
`sbsigntools` to sign it, and `systemd-boot-unsigned` to provide
65+
the bootloader binary. We remove `bootupd` because this example
66+
uses systemd-boot rather than bootupd + GRUB.
67+
68+
This stage also signs systemd-boot with our db key:
69+
70+
```dockerfile
71+
RUN --mount=type=secret,id=secureboot_key \
72+
--mount=type=secret,id=secureboot_cert <<EOF
73+
sbsign --key /run/secrets/secureboot_key \
74+
--cert /run/secrets/secureboot_cert \
75+
--output /tmp/systemd-bootx64.efi \
76+
/usr/lib/systemd/boot/efi/systemd-bootx64.efi
77+
install -m 0644 /tmp/systemd-bootx64.efi \
78+
/usr/lib/systemd/boot/efi/systemd-bootx64.efi
79+
EOF
80+
```
81+
82+
Note the use of `--mount=type=secret`. The private key is mounted
83+
into the build step but is never copied into any image layer. This
84+
is how we keep key material out of the final image.
85+
86+
### Stage 2: flatten to a single layer
87+
88+
```dockerfile
89+
FROM scratch AS base
90+
COPY --from=rootfs-builder / /
91+
LABEL containers.bootc 1
92+
LABEL ostree.bootable 1
93+
```
94+
95+
This stage copies the entire filesystem from stage 1 into a `FROM
96+
scratch` image, which flattens everything into a single layer.
97+
This is important because the composefs digest is computed over
98+
the complete filesystem tree. If the image had multiple layers,
99+
the digest would depend on how those layers happened to be stacked,
100+
which could vary depending on the container runtime and storage
101+
driver. (For more on why multi-layer images can produce
102+
non-deterministic results, see the earlier post on
103+
[pitfalls of incomplete tar archives](@/blog/2025-dec-15-blog-containers-pitfalls-of-incomplete-tar-archives.md).)
104+
Flattening eliminates that variable.
105+
106+
### Stage 3: generate and sign the UKI
107+
108+
```dockerfile
109+
FROM base AS kernel
110+
RUN --mount=type=bind,from=base,target=/target \
111+
--mount=type=secret,id=secureboot_key \
112+
--mount=type=secret,id=secureboot_cert <<EOF
113+
bootc container ukify --rootfs /target \
114+
-- \
115+
--signtool sbsign \
116+
--secureboot-private-key /run/secrets/secureboot_key \
117+
--secureboot-certificate /run/secrets/secureboot_cert \
118+
--output /out/uki.efi
119+
EOF
120+
RUN kver=$(bootc container inspect --json | jq -r '.kernel.version') && \
121+
mkdir -p /boot/EFI/Linux && \
122+
mv /out/uki.efi "/boot/EFI/Linux/${kver}.efi"
123+
```
124+
125+
This is where the magic happens. `bootc container ukify` does
126+
several things in one step:
127+
128+
1. Reads the filesystem at `/target` (the flattened base from
129+
stage 2, mounted via `--mount=type=bind`).
130+
2. Computes the composefs digest (a SHA-512 hash of the EROFS
131+
image that describes the complete filesystem).
132+
3. Discovers the kernel and initramfs from the filesystem.
133+
4. Assembles a UKI containing the kernel, initramfs, and a
134+
command line that includes `composefs=<digest>`.
135+
5. Signs the UKI with the db key via `sbsign`.
136+
137+
The result is a single `.efi` file that embeds a cryptographic
138+
commitment to the exact filesystem it was built from, signed by
139+
a key we control.
140+
141+
A second `RUN` step then discovers the kernel version and places
142+
the UKI at the expected path under `/boot/EFI/Linux/`.
143+
144+
### Stage 4: the final image
145+
146+
```dockerfile
147+
FROM base
148+
COPY --from=kernel /boot /boot
149+
```
150+
151+
The final image takes the flattened base and overlays the `/boot`
152+
directory from the kernel stage. This gives us a complete image:
153+
the sealed root filesystem plus the signed UKI that references it.
154+
155+
## Building the image
156+
157+
With the Containerfile and keys in place, building the image is a
158+
single `podman build` command:
159+
160+
```
161+
$ podman build \
162+
--secret id=secureboot_key,src=target/keys/sb-db.key \
163+
--secret id=secureboot_cert,src=target/keys/sb-db.crt \
164+
-t localhost/sealed-host:latest \
165+
.
166+
```
167+
168+
The two `--secret` flags make the db private key and certificate
169+
available to the build stages that need them, without ever
170+
persisting them in the image.
171+
172+
If you're using the examples repository, the
173+
[Justfile](https://github.com/redhat-cop/rhel-bootc-examples/blob/main/sealing/Justfile)
174+
wraps this for convenience:
175+
176+
```
177+
$ just keygen # generate keys (one-time)
178+
$ just build # build the sealed image
179+
```
180+
181+
## Secret handling in CI
182+
183+
The examples repository includes a
184+
[GitHub Actions workflow](https://github.com/redhat-cop/rhel-bootc-examples/blob/main/sealing/.github/workflows/build-sealed.yml)
185+
that demonstrates how to handle key material in CI. The db private
186+
key is stored as a GitHub Actions secret and written to a temporary
187+
file during the build.
188+
189+
For pull request builds, where the secret is not available, the
190+
workflow generates an ephemeral key pair on the fly. This allows
191+
PRs to validate that the build works without requiring access to
192+
production key material.
193+
194+
## What's next
195+
196+
At this point we have a sealed, signed container image. The UKI
197+
inside is signed with our Secure Boot key and embeds a composefs
198+
digest that covers every file in the operating system. In the
199+
next post, we'll deploy this image to a system and verify the
200+
seal is active.

0 commit comments

Comments
 (0)