RAM GOPINATHAN
RAM GOPINATHAN
  • September 14, 2026
  • 7 min read

Provisioning Devices and Onboarding to FlightCTL

This post picks up where the last one left off. With FlightCTL running on EKS behind Keycloak OIDC and RBAC groups wired up, the piece still missing is the other half of the fleet: an operating system image with the FlightCTL agent baked in, an AMI built from that image, and a handful of test devices that enroll into the fleet on first boot.

All the Make targets, Ansible playbooks, and cloud-init templates referenced below live in the edge-fleet-management repo, alongside the Terraform and Helm values used for the EKS setup.

Building a bootc image with the FlightCTL agent

bootc treats the operating system itself as a container image: the base OS, the FlightCTL agent, and any other packages get built into a single OCI image, and updates are applied transactionally with rpm-ostree rather than through package-by-package drift. The base image target builds and pushes that image to a registry:

make base REGISTRY=<specify>

Under the hood, base is three podman commands: build images/fedora-bootc-base/Containerfile for --arch amd64, tag it for the registry, and push. REGISTRY, BOOTC_BASE_IMAGE, and BOOTC_BASE_IMAGE_TAG are Makefile variables (defaulting to quay.io/rprakashg, fedora-bootc-base, and latest), which is why REGISTRY=<specify> on the command line is enough to point the push at your own registry.

Layering in cloud-init

The base image doesn't know anything about first-boot configuration on its own, so a second target layers cloud-init support on top of it. This is what lets us inject the admin user, SSH key, and — most importantly — the FlightCTL enrollment credentials at boot time, instead of baking device-specific secrets into the image itself:

make cloudinit

Concretely, cloudinit builds images/cloud-init/Containerfile with the base image passed in as a build arg, then tags and pushes the result as ${BOOTC_BASE_IMAGE}:aws. That :aws-tagged image is the artifact make ami turns into an actual EC2 image. Cool thing about cloud-init is that it provides a standard mechanism to perform first boot configurations regardless you are provisioning in public/private cloud or virtual or bare metal instances. If you are new to cloud-init please check out this article

Building the AMI

Turning the bootc image into something EC2 can boot means importing it as an AMI, which in turn requires the AWS account to have a vmimport service role configured. An Ansible playbook handles that one-time setup:

export VAULT_SECRET=<redacted>

ansible-playbook --vault-password-file <(echo "$VAULT_SECRET") configure_aws.yml

With vmimport in place, build the AMI:

make ami

ami pulls the :aws-tagged image and hands it to quay.io/centos-bootc/bootc-image-builder, which does the actual OS-image-to-AMI conversion: --type ami --rootfs xfs, your AWS credentials and container storage bind-mounted in, and an intermediate S3 bucket (bootc-images) it stages through along the way.

Vaulting device secrets

The cloud-init template used later on needs a few real secrets — an admin username, password, SSH public key, and an EC2 key pair name — so those go into an Ansible vault rather than a plain vars file:

admin_user: <redacted>
admin_user_password: <redacted>
admin_user_ssh_pubkey: <redacted>
key_name: <redacted>

Create the vault and keep its password in an environment variable so the playbooks below can unlock it non-interactively:

cd playbooks
ansible-vault create vars/secrets.yaml
export VAULT_SECRET=<redacted>

Provisioning test devices

The repo provisions test devices via an Ansible playbook driven by the device-without-microshift.yaml vars file — ideal for demonstrating Podman, legacy VMs, and RPM apps:

device_count in the vars file controls how many instances get launched — bump it to provision a larger batch. Update the AMI ID, subnet, and security group in the vars file to match your environment first, then run:

ansible-playbook --vault-password-file <(echo "$VAULT_SECRET") launch_instance.yaml -e @vars/device-without-microshift.yaml

How the enrollment credentials actually get onto the device

Credentials are injected using a late-binding approach rather than baked into the image at build time. The enrollment certificate generated in the previous post (flightctl certificate request --signer=enrollment --expiration=365d --output=embedded > config.yaml) produces a config.yaml containing the device's client cert, key, and the FlightCTL CA bundle. A Jinja template turns that file into cloud-init user-data:

#cloud-config
users:
- name: "{{ admin_user }}"
  ssh_authorized_keys:
  - "{{ admin_user_ssh_pubkey }}"
  groups: ["wheel"]
  passwd: "{{ admin_user_password | password_hash('sha512') }}"
  shell: /bin/bash
  sudo: ['ALL=(ALL) NOPASSWD:ALL']

runcmd:
- echo "Welcome to bootc on fedora" > /etc/motd
- systemctl enable --now flightctl-agent.service

write_files:
- path: /etc/flightctl/config.yaml
  permissions: '0755'
  owner: root:root
  content: |
    {{ lookup('file', 'config.yaml') | from_yaml | to_nice_yaml | indent(4) }}

The Ansible EC2 module just points user_data at that template:

- name: Launch instance
  amazon.aws.ec2_instance:
    name: "{{ instance_name }}"
    instance_type: "{{ instance_type }}"
    image_id: "{{ ami }}"
    subnet_id: "{{ subnet_id }}"
    security_group: "{{ security_group_id }}"
    count: "{{ device_count }}"
    user_data: "{{ lookup('ansible.builtin.template', './templates/user-data.j2') }}"
    tags:
      Name: "{{ instance_name }}"

No device-specific secret ever lives in the AMI itself — it's assembled fresh at launch time from the vault and the enrollment config generated for that batch of devices.

Verifying enrollment

Once an instance boots, cloud-init writes /etc/flightctl/config.yaml and starts flightctl-agent.service, which immediately calls home over the mTLS-only agent endpoint set up in the last post. Back in the FlightCTL UI, the device shows up as a pending enrollment request, waiting for an operator or admin in one of the RBAC groups created earlier to approve it.

That's the full loop: bootc image → AMI → Ansible-provisioned EC2 device → cloud-init-injected credentials → pending enrollment in FlightCTL.

In a follow up post we will look at injecting enrollment credentials into device in a FDO device onboarding flow

DeviceCloud-initAWSEdgeOSContainers