Building and Deploying Image Mode Systems on AWS
Build, ship, and update your EC2 operating system like a container. This step-by-step guide takes a RHEL 10 image mode (bootc) image from a Containerfile to a running instance using Amazon ECR and bootc-image-builder, then covers Day-2 updates, rollback, and taking it to production.
Building and Deploying RHEL Image Mode Systems on AWS
Why image mode on AWS
Image mode for RHEL lets you build, ship and update an EC2 operating system the same way you ship an application container: write a Containerfile, push to a registry, and let every instance pull the new version. This walkthrough takes a RHEL 10 bootc image from a Containerfile to a running EC2 instance, using Amazon ECR as the registry and an AMI as the boot artifact.
The payoff is a host OS that is versioned, reproducible and reversible. Instead of patching instances in place and hoping they stay identical, you build one image, test it in CI, and roll it out. If an update misbehaves,
bootc rollback returns the host to the previous image.This guide is for platform engineers and SREs who already know containers and EC2 and want a repeatable OS pipeline, especially in regulated environments where drift is a compliance problem.
How the pieces fit

The AMI is built once to give new instances a first boot. After that, every OS change is a new image tag in ECR, and each host pulls it with
bootc upgrade.Prerequisites
You need a RHEL build host, an AWS account, and a few AWS resources set up before the first build.
| Item | Why it is needed |
|---|---|
| RHEL 10 host (x86_64 or arm64) with Podman and root access | bootc-image-builder runs as a --privileged container, and the host architecture must match the target AMI |
| Red Hat account, logged in to registry.redhat.io | Pulls the rhel10/rhel-bootc base image and rhel10/bootc-image-builder |
| AWS CLI v2 with credentials in ~/.aws | bootc-image-builder uses these credentials to upload and register the AMI |
| An S3 bucket in the target region | Intermediate storage for the disk image; bootc-image-builder will not create it for you |
| The vmimport service role | Lets EC2 VM Import turn the uploaded disk into an AMI |
| An ECR private repository | Holds the bootc image that instances pull updates from |
| An EC2 instance profile with ECR read access | Lets running hosts authenticate to ECR for updates |
Create the S3 bucket and ECR repository:
1
2
3
4
5
6
7
8
9
export AWS_REGION=us-east-1
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export REPO=rhel-bootc-web
export ECR=${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
aws s3 mb s3://rhel-bootc-ami-${ACCOUNT_ID} --region ${AWS_REGION}
aws ecr create-repository --repository-name ${REPO} \
--image-scanning-configuration scanOnPush=true \
--region ${AWS_REGION}
Optional: create the vmimport service role
Skip this if your account already has a
vmimport role (aws iam get-role --role-name vmimport returns it). Otherwise, create it once per account.- Write a trust policy that lets the VM Import service assume the role:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cat > trust-policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "vmie.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:Externalid": "vmimport" }
}
}
]
}
EOF- Create the role:
1
2
aws iam create-role --role-name vmimport \
--assume-role-policy-document file://trust-policy.json- Write a role policy scoped to your AMI bucket:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
cat > role-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:PutObject",
"s3:GetBucketAcl"
],
"Resource": [
"arn:aws:s3:::rhel-bootc-ami-${ACCOUNT_ID}",
"arn:aws:s3:::rhel-bootc-ami-${ACCOUNT_ID}/*"
]
},
{
"Effect": "Allow",
"Action": [
"ec2:ModifySnapshotAttribute",
"ec2:CopySnapshot",
"ec2:RegisterImage",
"ec2:Describe*"
],
"Resource": "*"
}
]
}
EOF- Attach the policy to the role and confirm it is in place:
1
2
3
4
5
aws iam put-role-policy --role-name vmimport \
--policy-name vmimport \
--policy-document file://role-policy.json
aws iam get-role-policy --role-name vmimport --policy-name vmimportIf your bucket uses SSE-KMS encryption, also grant the role
kms:Decrypt and kms:GenerateDataKey on that key. The IAM user or role that runs bootc-image-builder needs iam:PassRole on vmimport, plus S3 write and ec2:ImportSnapshot permissions.Step 1: Write the Containerfile
The Containerfile is the whole OS definition: base image, packages, services and configuration, all in one reviewable file. This example builds a web server host with cloud-init so EC2 can inject SSH keys and user data at launch.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FROM registry.redhat.io/rhel10/rhel-bootc:10
# Packages: cloud-init for EC2 metadata, httpd as the example workload
RUN dnf -y install cloud-init httpd && \
ln -s ../cloud-init.target /usr/lib/systemd/system/default.target.wants && \
systemctl enable httpd && \
dnf clean all && \
rm -rf /var/{cache,log} /var/lib/{dnf,rhsm}
# Application content lives under /usr so every update replaces it
# (content under /var is only seeded at first install)
COPY index.html /usr/share/www/html/index.html
COPY docroot.conf /usr/lib/httpd-docroot.conf
RUN ln -s /usr/lib/httpd-docroot.conf /etc/httpd/conf.d/zz-docroot.conf
# Lint the image so bootc catches common mistakes at build time
RUN bootc container lint
Three habits keep image mode builds clean:
- Put immutable content under
/usr. The/usrtree is read-only at runtime and replaced on every update. - Treat
/etcas default configuration. bootc performs a three-way merge of/etcon update, so local edits survive but drift is possible. - Keep state in
/var. It persists across updates and rollbacks and is never overwritten by a new image.
Next to the Containerfile, add an
index.html (any content that shows a version, so you can confirm which image is serving) and a docroot.conf that points httpd at the image-owned content:1
2
3
4
DocumentRoot "/usr/share/www/html"
<Directory "/usr/share/www/html">
Require all granted
</Directory>
Step 2: Build and push the image to Amazon ECR
Build as root so the image lands in root's container storage, where bootc-image-builder reads it in the next step. Tag with both a version and
latest: instances track a tag, and the version tag gives you an exact image to switch back to.1
2
3
4
5
6
7
8
9
sudo podman login registry.redhat.io
sudo podman build -t ${ECR}/${REPO}:1.0 -t ${ECR}/${REPO}:latest .
aws ecr get-login-password --region ${AWS_REGION} | \
sudo podman login --username AWS --password-stdin ${ECR}
sudo podman push ${ECR}/${REPO}:1.0
sudo podman push ${ECR}/${REPO}:latest
Before you make an AMI, smoke-test the image as a container. It will not boot systemd the way a VM does, but it catches missing packages and broken config fast:
1
sudo podman run --rm -it ${ECR}/${REPO}:1.0 rpm -q httpd cloud-init
With
scanOnPush enabled, ECR scans each pushed tag for CVEs, so the OS image gets the same vulnerability findings as your application images.Step 3: Convert the image to an AMI with bootc-image-builder
bootc-image-builder turns the container image into a bootable disk, uploads it to your S3 bucket and registers it as an AMI in one command. The three --aws-* flags must be passed together; without them the AMI is written to a local output directory instead.An optional
config.toml adds a break-glass user, useful before cloud-init is proven:1
2
3
4
[[customizations.user]]
name = "ec2-admin"
key = "ssh-ed25519 AAAA... you@example.com"
groups = ["wheel"]
Run the builder:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mkdir -p ./output
sudo podman run --rm -it --privileged \
--pull=newer \
--security-opt label=type:unconfined_t \
-v ./config.toml:/config.toml:ro \
-v ./output:/output \
-v /var/lib/containers/storage:/var/lib/containers/storage \
-v $HOME/.aws:/root/.aws:ro \
--env AWS_PROFILE=default \
registry.redhat.io/rhel10/bootc-image-builder:latest \
--type ami \
--config /config.toml \
--aws-ami-name rhel-bootc-web-1.0 \
--aws-bucket rhel-bootc-ami-${ACCOUNT_ID} \
--aws-region ${AWS_REGION} \
${ECR}/${REPO}:1.0
The
/var/lib/containers/storage mount lets the builder use the image you just built locally instead of pulling it again. When the run finishes, the new AMI ID appears in the output and in the EC2 console under AMIs.You build an AMI rarely: only for new instances or a new base. Day-to-day OS changes flow through the registry, not new AMIs.
Step 4: Launch an EC2 instance and verify
Launch the AMI like any other, attaching an instance profile that can read from ECR (the AWS managed policy
AmazonEC2ContainerRegistryReadOnly is enough):1
2
3
4
5
6
7
8
aws ec2 run-instances \
--image-id ami-0123456789abcdef0 \
--instance-type t3.medium \
--key-name my-key \
--iam-instance-profile Name=bootc-ecr-read \
--security-group-ids sg-0123456789abcdef0 \
--subnet-id subnet-0123456789abcdef0 \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=bootc-web-01}]'
SSH in and confirm the host knows which image it runs and where updates come from:
1
2
sudo bootc status
curl -s localhost | head
bootc status shows the booted image, its digest, and any staged or rollback deployment.Let the host authenticate to ECR
bootc reads registry credentials from /etc/ostree/auth.json. ECR tokens expire after 12 hours, so a static file will not work for long. The simplest pattern is a systemd timer, baked into the image, that refreshes the file from the instance role. Add the AWS CLI to the image, then:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[]
Description=Refresh ECR credentials for bootc
Wants=network-online.target
After=network-online.target
Before=bootc-fetch-apply-updates.service
[]
Type=oneshot
EnvironmentFile=/etc/sysconfig/ecr-auth
ExecStart=/bin/sh -c 'aws ecr get-login-password --region ${AWS_REGION} | podman login --authfile /etc/ostree/auth.json --username AWS --password-stdin ${ECR_REGISTRY}'
[]
OnBootSec=1min
OnUnitActiveSec=6h
[]
WantedBy=timers.target
Set
AWS_REGION and ECR_REGISTRY in /etc/sysconfig/ecr-auth through the Containerfile or cloud-init, and enable the timer in the image with systemctl enable ecr-auth.timer. The Amazon ECR credential helper is an alternative if you prefer not to manage a token file.Step 5: Day-2 updates and rollback with bootc
Updating the fleet is a container push. Change the Containerfile (bump
index.html to 1.1, add a package), then rebuild and push:1
2
3
sudo podman build -t ${ECR}/${REPO}:1.1 -t ${ECR}/${REPO}:latest .
sudo podman push ${ECR}/${REPO}:1.1
sudo podman push ${ECR}/${REPO}:latest
On the instance, pull and stage the update. It applies at the next boot, or immediately with
--apply:1
2
sudo bootc upgrade # download and stage
sudo bootc upgrade --apply # stage and reboot now
On RHEL 10, many userspace-only updates can skip the hardware reboot with a
systemd soft reboot. --soft-reboot=auto uses it when possible and falls back to a normal reboot otherwise:1
sudo bootc upgrade --apply --soft-reboot=auto
The controls you will use most:
| Command | What it does |
|---|---|
| bootc upgrade --download-only | Fetches the update without staging it, for a controlled maintenance window |
| bootc switch ${ECR}/${REPO}:1.0 | Points the host at a different image or tag, for pinning or canaries |
| bootc rollback --apply | Boots the previous deployment; the current one becomes the rollback |
| systemctl disable --now bootc-fetch-apply-updates.timer | Turns off automatic updates when an orchestrator drives rollouts |
Two rollback details matter in production. Local changes to
/etc do not carry into the rolled-back deployment, so keep anything you need under /var. And if the automatic update timer is still on, the host will move forward to latest again, so disable it or switch to a pinned tag before rolling back.Taking it to production
The manual flow above becomes a pipeline once the OS is an image. A production setup usually adds four things:
- CI builds on every commit. Build the Containerfile in AWS CodeBuild or GitHub Actions, run
bootc container lint, and push immutable version tags. Only promote tolatest(or aprodtag) after tests pass. - Signing and SBOMs. Sign each image with Cosign (a KMS key works well on AWS) and attach an SBOM generated with Syft. Configure
/etc/containers/policy.jsonon hosts to reject unsigned images, so a compromised registry cannot push an OS. - Staged rollouts. Give canary instances a
canarytag and the restprod, then usebootc switchor tag promotion to move groups forward. AWS Systems Manager Run Command can triggerbootc upgrade --applyacross a tagged fleet during a maintenance window. - Golden AMI refresh. Rebuild the AMI on a schedule so new instances do not boot an old image and immediately pull a large update. A scheduled pipeline job that runs bootc-image-builder against the current prod tag is enough.
For regulated environments, the Containerfile history plus image signatures gives auditors a clear record of exactly what ran on each host, and when.
Wrapping up
With image mode, the EC2 operating system becomes one more artifact in your container pipeline: built from a Containerfile, stored in ECR, scanned on push, and rolled forward or back with a single bootc command. The AMI is just the first boot; everything after that is a registry push.
A good next step is to wire the build into CI with signing, then run a canary group on a separate tag for a week before moving the rest of the fleet.
Resources
- Creating AMI images with bootc-image-builder and uploading them to AWS (RHEL 10 docs)
- Managing RHEL bootc images: switch, upgrade, rollback (RHEL 10 docs)
- Initiating a soft reboot (RHEL 10 docs)
- Image mode for RHEL 10: updates in seconds with soft reboot (Red Hat Developer)
- bootc-image-builder documentation (osbuild)
- Image mode for RHEL quick start guide (Red Hat blog)
- Authenticating Amazon ECR with the credential helper (AWS Compute Blog)
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article