Converting a QCOW2 image to an AWS AMI
If you build VM images on Linux, you almost certainly build them in QCOW2 — it is QEMU’s native format and what the whole KVM toolchain produces. If you deploy on AWS, you need an AMI. Getting from one to the other is not hard, but it has enough sharp edges that it is worth walking through end to end.
AWS does not import QCOW2
The first thing to know is that AWS VM Import/Export does not accept QCOW2. The supported disk formats are RAW, VHD, and VMDK (and OVA for full appliances). So the first step is always a conversion.
QEMU ships the tool for it:
qemu-img convert -f qcow2 -O raw image.qcow2 image.raw
RAW is the safest target for import. It is an unstructured, byte-for-byte disk dump — large on disk, but there is nothing for the import process to misinterpret. If disk size or upload time matters, VHD is a more compact option (-O vpc in qemu-img, which is the format’s historical name in QEMU).
Step 1: the vmimport IAM role
This is the step that stops most first attempts. VM Import/Export runs as an AWS service that needs permission to read your uploaded disk from S3 and create an EBS snapshot from it. That permission is granted through a specifically named IAM role called vmimport, with a trust policy that lets the VM Import service assume it.
You create the role once per account, with a trust relationship scoped to vmie.amazonaws.com, and a policy granting it read access to your import bucket and the EC2 snapshot and import actions. AWS documents the exact JSON; the thing to internalize is that a disk validation failed or permission error on import almost always traces back to this role being missing or too narrowly scoped, not to the disk.
Step 2: upload to S3
aws s3 cp image.raw s3://my-import-bucket/image.raw
The bucket must be in the same region you intend to import into. Cross-region adds cost and latency for no benefit.
Step 3: import the disk
Point import-image at the uploaded object. A small JSON file describes the container:
[
{
"Description": "hardened-ubuntu-2404",
"Format": "raw",
"UserBucket": {
"S3Bucket": "my-import-bucket",
"S3Key": "image.raw"
}
}
]
aws ec2 import-image \
--description "hardened-ubuntu-2404" \
--disk-containers file://containers.json
This returns an ImportTaskId. The import is asynchronous and not fast — a large disk takes a while to convert and snapshot. Poll it:
aws ec2 describe-import-image-tasks --import-task-ids import-ami-0abc123
When Status reaches completed, the task output includes the new AMI ID.
The gotchas that actually cost time
Imports are rate-limited. AWS allows a default of 5 concurrent import tasks per region, raisable to 20 through a support request. If you are publishing an image to many regions, you cannot fire all the imports at once; they have to queue against that limit. Any automation you build around this has to respect it, or AWS will reject the excess.
AMIs are regional. The AMI that import produces exists in exactly one region. To run it elsewhere you copy-image to each target region, and every copy gets a different AMI ID. Anything downstream that references the AMI — a launch template, an autoscaling group, a Terraform module — has to be parameterized per region. This surprises people who expect an image to be a global object; on AWS it is not.
UEFI vs BIOS boot mode. Modern images often expect UEFI. import-image infers a boot mode, but you can set it explicitly with --boot-mode uefi. A mismatch produces an AMI that imports fine and then will not boot, which is a slow way to discover the problem.
Windows is a one-way door. If you import a Windows image, AWS applies its own licensing on the way in, and the result cannot be exported back out of EC2. That makes EC2 a terminal destination for a Windows build, not a staging point you re-publish from. Build Windows once and publish outward to each destination — do not plan to move it cloud-to-cloud afterward.
Doing it repeatably
For a single image, the sequence above is an afternoon. The friction shows up when it becomes routine: every new build re-runs the conversion, the upload, the import, and the multi-region copy, all while staying under the concurrency limit and keeping track of which AMI ID landed in which region. And the AMI is only half the artifact — the SBOM, the CVE scan, and the signature that describe what is actually in it live somewhere else unless you deliberately keep them together.
That publish-and-track loop is what Primcoat’s AWS integration handles: it converts the hardened master to RAW, imports it, copies it to every region you target while respecting the import limit, and keeps the evidence attached to the build that produced each AMI. The commands here are worth understanding either way — but if you are running them on every release, that is a pipeline, not a task.
Machine-readable:/blog/qcow2-to-aws-ami.md