RAM GOPINATHAN
RAM GOPINATHAN
  • 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 build produces something you can podman run --systemd=always for a fast local loop, or bootc install onto 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/files

Pinning 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.target

A few details worth calling out:

  • StateDirectory/CacheDirectory let systemd manage the service's writable directories under /var/lib and /var/cache with correct ownership, rather than baking mkdir/chown into the image or the unit's ExecStartPre.
  • 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 in ExecStart, 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-pager

All 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 with podman run -p 18041:8041 from the host instead of the container's own network), device_port and owner_port are not interchangeable, even though both point at the same rendezvous server. device_port is what the external device dials, so it needs the host-mapped port. owner_port is 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.bin

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

That'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:latest

Once 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 Containerfile and 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-dsn flags 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.

FDORHEL