- September 9, 2026
- 10 min read
Building an all in one FDO server infrastructure on Image Mode RHEL
I've written before about what FDO is and why it matters for scalable, secure device onboarding. This post skips the theory and goes straight to the build: packaging all three FDO roles — manufacturing, rendezvous, and owner — into a single RHEL image mode (bootc) image, so you can stand up a complete onboarding environment from one podman build and one boot.
The result is fdo-aio-server built on top of the community go-fdo-server project — an "all-in-one" image for local testing, demos, and CI, not a production topology (more on that at the end).
Why one image, three roles
In a real deployment, the manufacturing, rendezvous, and owner servers are separate services with different trust boundaries — a manufacturer doesn't necessarily run the owner infrastructure, and rendezvous is often a neutral third party. But when you're developing against FDO, standing up three separate hosts (or three separate containers with shared volumes for certs) is friction that gets in the way of just trying things out.
Image mode is a good fit here because the unit of deployment is the same either way: a bootc image is a regular OCI container image that also happens to boot as a full RHEL system with systemd as PID 1. That means:
- One build artifact.
podman buildproduces something you canpodman run --systemd=alwaysfor a fast local loop, orbootc installonto a disk/VM for something closer to how it'd run in the field. - systemd does the service supervision, not a container entrypoint script juggling three background processes. Each FDO role is its own unit with its own restart policy, state directory, and log stream.
- Customization happens through systemd drop-ins, the same mechanism RHEL admins already use, instead of environment-variable sprawl or rebuilding the image for every config tweak.
The Containerfile
The build is a two-stage Containerfile. The first stage compiles go-fdo-server from source using Red Hat's Go toolset image; the second stage starts from rhel-bootc and layers in the binary, systemd units, and test PKI.
FROM registry.redhat.io/rhel9/go-toolset:9.8-1788409979 AS builder
ARG REPO_URL=https://github.com/fido-device-onboard/go-fdo-server.git
ARG REPO_REF=main
WORKDIR ./go-fdo-server
RUN git clone --branch ${REPO_REF} --depth 1 ${REPO_URL} .
RUN make build
FROM registry.redhat.io/rhel9/rhel-bootc:latest
COPY etc /etc
COPY --from=builder /opt/app-root/src/go-fdo-server/go-fdo-server /usr/bin/go-fdo-server
RUN mkdir -p /etc/fdo/db/ /etc/fdo/pki /etc/fdo/filesPinning REPO_REF as a build arg means you can build against a tagged release instead of main once one exists, without touching the Containerfile.
Generating test PKI at build time
FDO needs three key/cert pairs: a manufacturer key, a device CA key/cert pair (shared between manufacturing and owner), and an owner key/cert pair. For a throwaway test image, generating self-signed certs during the build is the simplest option:
# Manufacturer key (DER format)
RUN openssl ecparam -name prime256v1 -genkey -out /etc/fdo/pki/manufacturer_key.der -outform der
RUN openssl req -x509 -key /etc/fdo/pki/manufacturer_key.der -keyform der \
-out /etc/fdo/pki/manufacturer_cert.pem -days 365 \
-subj "/C=US/O=Example/CN=Manufacturer"
# Device CA key (DER format) — shared by manufacturing and owner
RUN openssl ecparam -name prime256v1 -genkey -out /etc/fdo/pki/device_ca_key.der -outform der
RUN openssl req -x509 -key /etc/fdo/pki/device_ca_key.der -keyform der \
-out /etc/fdo/pki/device_ca_cert.pem -days 365 \
-subj "/C=US/O=Example/CN=Device CA"
# Owner key (DER format)
RUN openssl ecparam -name prime256v1 -genkey -out /etc/fdo/pki/owner_key.der -outform der
RUN openssl req -x509 -key /etc/fdo/pki/owner_key.der -keyform der \
-out /etc/fdo/pki/owner_cert.pem -days 365 \
-subj "/C=US/O=Example/CN=Owner"The one thing worth being deliberate about here: the filenames have to match exactly what the systemd units pass on the command line. It's an easy place for drift to creep in — rename one side during a refactor and the services fail silently at boot with a "file not found" a layer down in the FDO library, not a friendly error at the top. I keep the naming convention (<role>_key.der, <role>_cert.pem) consistent between the Containerfile and the unit files specifically so this doesn't happen.
Enabling the services
Because this is a bootc image and not a running system, systemctl enable works fine as a plain build step — it's just creating symlinks under /etc/systemd/system/multi-user.target.wants/, no running systemd instance required:
RUN systemctl enable fdo-manufacturing fdo-rendezvous fdo-owner
EXPOSE 8038
EXPOSE 8043
EXPOSE 8041
CMD [ "/sbin/init" ]That last line is the bootc convention: the container's entrypoint is /sbin/init, i.e. systemd itself. Whether you run this with podman run --systemd=always or boot it as a real image-mode host, the same units come up the same way.
The systemd units
Each FDO role gets its own unit, all following the same shape. Here's the manufacturing one:
[Unit]
Description=FDO Manufacturing Server
Documentation=https://github.com/fido-device-onboard/go-fdo-server
After=network.target
[Service]
Type=simple
PrivateDevices=yes
StateDirectory=go-fdo-manufacturing
CacheDirectory=go-fdo-manufacturing
Environment=PORT=8038
ExecStart=/usr/bin/go-fdo-server manufacturing 0.0.0.0:${PORT} \
--db-type sqlite \
--db-dsn file:/etc/fdo/db/mfg.db \
--manufacturing-key /etc/fdo/pki/manufacturer_key.der \
--device-ca-cert /etc/fdo/pki/device_ca_cert.pem \
--device-ca-key /etc/fdo/pki/device_ca_key.der \
--owner-cert /etc/fdo/pki/owner_cert.pem
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetA few details worth calling out:
StateDirectory/CacheDirectorylet systemd manage the service's writable directories under/var/liband/var/cachewith correct ownership, rather than bakingmkdir/chowninto the image or the unit'sExecStartPre.PrivateDevices=yes— each FDO role is a plain HTTP service with a SQLite backend; none of them need device access, so this is free hardening.- The port is an
Environment=variable, not hardcoded inExecStart, specifically so it can be overridden with a one-line drop-in instead of a full unit rewrite:
$ systemctl edit fdo-owner.service[Service]
Environment=PORT=9043
ExecStart=
ExecStart=/usr/bin/go-fdo-server owner 0.0.0.0:${PORT} --log-level debug ...That empty ExecStart= line is the standard systemd idiom for clearing the inherited ExecStart before redefining it — without it, the drop-in would add a second ExecStart line rather than replace the original.
The manufacturing and owner units share two files (device_ca_cert.pem, and the owner cert/key indirectly via the voucher chain), which is why the comments in both units call that out explicitly — if you ever split this into separate images per role, those files are what has to travel together.
Build it
podman build -t fdo-aio-server:latest .That's the whole build — no external volumes, no post-build config step. Everything the services need ships in the image.
Smoke-test with podman before going anywhere near a disk image
You don't need bootc install to check that this actually works — podman run --systemd=always boots the same systemd tree inside a normal container, which is a fast loop for iterating on unit files:
podman run -d --name fdo-test --systemd=always \
-p 8038:8038 -p 8043:8043 -p 8041:8041 \
fdo-aio-server:latest
podman exec fdo-test systemctl status fdo-manufacturing fdo-rendezvous fdo-owner --no-pagerAll three should come up active (running) within a couple of seconds, each logging that it's listening on its port:
● fdo-manufacturing.service - FDO Manufacturing Server
Active: active (running)
...
└─100 /usr/bin/go-fdo-server manufacturing 0.0.0.0:8038 ...And each exposes a /health endpoint:
curl -fsS http://127.0.0.1:8038/health
curl -fsS http://127.0.0.1:8041/health
curl -fsS http://127.0.0.1:8043/health
# {"message":"the service is up and running","status":"OK","version":"1.0.0"}Proving it end-to-end: an actual onboarding
Standing up three healthy services isn't the same as proving onboarding works. The go-fdo-client project is the companion device-side tool, and it's worth running the full cycle against the image at least once rather than taking it on faith.
First, tell the manufacturing server where rendezvous lives (RVInfo), and tell the owner server where it lives for the device to reach it after redirection (RVTO2Addr):
curl -X PUT 'http://localhost:8038/api/v2/rvinfo' \
-H 'Content-Type: application/json' \
-d '[[{"dns":"127.0.0.1"},{"device_port":8041},{"owner_port":8041},{"protocol":"http"},{"ip":"127.0.0.1"}]]'
curl -X PUT 'http://localhost:8043/api/v2/rvto2addr' \
-H 'Content-Type: application/json' \
-d '[{"dns":"127.0.0.1","port":8043,"protocol":"http","ip":"127.0.0.1"}]'If you're publishing ports through a NAT layer (e.g. testing withpodman run -p 18041:8041from the host instead of the container's own network),device_portandowner_portare not interchangeable, even though both point at the same rendezvous server.device_portis what the external device dials, so it needs the host-mapped port.owner_portis what the owner service — running in the same container as rendezvous — dials for TO0 registration, so it needs the real in-container port. Mixing these up produces a confusing "TO1.HelloRV ... not found" error on the device side, several steps downstream of the actual misconfiguration on the owner side. Worth knowing before you spend twenty minutes staring at the wrong log.
With that configured, run the device side:
# 1. Device Initialization — creates device credentials
go-fdo-client device-init 'http://localhost:8038' \
--device-info gotest --key ec256 --blob /tmp/cred.bin
# 2. Pull the GUID back out of the credential
GUID=$(go-fdo-client print --blob /tmp/cred.bin | grep -oE '[0-9a-fA-F]{32}' | head -n1)
# 3. Fetch the ownership voucher from manufacturing, hand it to owner
# (this is also what triggers TO0 registration with rendezvous)
curl -H 'Accept: application/x-pem-file' \
"http://localhost:8038/api/v2/vouchers/${GUID}" > /tmp/ownervoucher
curl -X POST 'http://localhost:8043/api/v2/vouchers' \
-H 'Content-Type: application/x-pem-file' --data-binary @/tmp/ownervoucher
# 4. Run TO1 + TO2
go-fdo-client onboard --key ec256 --kex ECDH256 --blob /tmp/cred.binOn a working setup, that last command ends with:
[..] INFO: Attempting TO1 protocol
[..] INFO: TO1 succeeded
[..] INFO: Attempting TO2 protocol
[..] INFO: TO2 succeeded
[..] INFO: FIDO Device Onboard CompleteThat's a full onboarding cycle — device initialization, voucher extension and transfer, TO0, TO1, TO2 — running against three systemd-managed services inside one bootc image.
From container to image mode
Everything above runs the image as a container, which is the right loop for iterating on units and config. Getting it onto real hardware or a VM as an actual RHEL image-mode host is a separate, later step:
# Push the built image somewhere bootc can pull it from
podman push fdo-aio-server:latest quay.io/<you>/fdo-aio-server:latest
# On the target — a bare-metal box or VM booted from a RHEL/CoreOS live ISO —
# install it to disk
sudo bootc install to-disk --wipe /dev/sda \
--source-imgref quay.io/<you>/fdo-aio-server:latest
# Or, on a host already running as bootc, switch it to this image in place
sudo bootc switch quay.io/<you>/fdo-aio-server:latestOnce it's booted, it behaves exactly like the container did — same units, same ports, same systemctl edit override pattern — because it's genuinely the same image, just running as the host OS instead of inside another one.
Where this doesn't belong yet
This is deliberately a test/demo image, and the things that make it convenient for that are exactly the things to change before it's near a real device fleet:
- The self-signed test certs are generated inline in the
Containerfileand baked into the image. Every image built from this file shares the same keys. For anything beyond local testing, generate real key material out-of-band and inject it (secrets, a volume, or a separate signing step) rather than shipping private keys in an OCI layer. - SQLite is fine for one host and light load; the
--db-type/--db-dsnflags on each unit are there specifically so you can point at Postgres instead without touching the binary. - All three roles trust each other implicitly by sharing a filesystem. In production these are typically separate services, often separate organizations, talking over the network with proper cert distribution — not three units reading from the same
/etc/fdo/pki.
For local development, CI pipelines that need a real FDO backend to test against, or a demo environment, none of that matters — that's exactly the set of tradeoffs that makes this useful. It's just worth being explicit about which side of that line you're on before you point real devices at it.
Related Posts
RAM GOPINATHAN
August 12, 2026
Building Kubernetes-Style APIs: A Practical Walkthrough
Kubernetes real innovation is the declarative, self-healing API pattern. This post builds that pattern from scratch: an OpenAPI-first server, a Postgres-backed store instead of etcd, and a clean service/store split that keeps business logic out of the database
RAM GOPINATHAN
February 28, 2026
Automating post install configurations with cloud-init for Red Hat Enterprise Linux deployments on baremetal environments
Stop configuring servers by hand. Here's how cloud-init brings cloud-level automation to your physical infrastructure.
RAM GOPINATHAN
January 22, 2026
PODMAN TIPS: Implementing init container pattern for containers running on PODMAN
If you’ve worked with Kubernetes, you’re probably familiar with init containers—containers that run before the main application and are typically used for setup or initialization tasks. In this post,…