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