diff --git a/docs/1-installing.md b/docs/1-installing.md
index b1c2822..44cb689 100644
--- a/docs/1-installing.md
+++ b/docs/1-installing.md
@@ -35,7 +35,7 @@ To also set up crun-vm for use with Docker:
 1. Install crun-vm's runtime dependencies:
 
    ```console
-   $ dnf install bash coreutils crun genisoimage grep libselinux-devel libvirt-client libvirt-daemon-driver-qemu libvirt-daemon-log openssh-clients qemu-img qemu-system-x86-core shadow-utils util-linux virtiofsd
+   $ dnf install bash coreutils crun crun-krun genisoimage grep libselinux-devel libvirt-client libvirt-daemon-driver-qemu libvirt-daemon-log openssh-clients qemu-img qemu-system-x86-core sed shadow-utils util-linux virtiofsd
    ```
 
 2. Install Rust and Cargo if you do not already have Rust tooling available:
diff --git a/docs/2-podman-docker.md b/docs/2-podman-docker.md
index 47be99c..a7d9926 100644
--- a/docs/2-podman-docker.md
+++ b/docs/2-podman-docker.md
@@ -96,6 +96,25 @@ in a container image.
 Note that flag `--persistent` has no effect when running VMs from container
 images.
 
+### From bootable container images
+
+crun-vm can also work with [bootable container images], which are containers
+that package a full operating system:
+
+```console
+$ podman run \
+    --runtime crun-vm \
+    -it --rm \
+    quay.io/fedora/fedora-bootc:40
+```
+
+Internally, crun-vm generates a VM image from the bootable container and then
+boots it.
+
+By default, the VM image is given a disk size roughly double the size of the
+bootc container image. To change this, use the `--bootc-disk-size <size>[KMGT]`
+option.
+
 ## First-boot customization
 
 ### cloud-init
@@ -317,8 +336,12 @@ $ podman run \
 ### System emulation
 
 To use system emulation instead of hardware-assisted virtualization, specify the
-`--emulated` flag. Without this flag, attempting to create a VM on a host tbat
-doesn't support KVM will fail.
+`--emulated` flag. Without this flag, attempting to create a VM from a guest
+with a different architecture from the host's or on a host that doesn't support
+KVM will fail.
+
+It's not currently possible to use this flag when the container image is a bootc
+bootable container.
 
 ### Inspecting and customizing the libvirt domain XML
 
@@ -340,6 +363,7 @@ be merged with it using the non-standard option `--merge-libvirt-xml <file>`.
 > Before using this flag, consider if you would be better served using libvirt
 > directly to manage your VM.
 
+[bootable container images]: https://containers.github.io/bootable/
 [cloud-init]: https://cloud-init.io/
 [domain XML definition]: https://libvirt.org/formatdomain.html
 [Ignition]: https://coreos.github.io/ignition/
diff --git a/embed/bootc/config.json b/embed/bootc/config.json
new file mode 100644
index 0000000..a40fc6c
--- /dev/null
+++ b/embed/bootc/config.json
@@ -0,0 +1,88 @@
+{
+    "ociVersion": "1.0.0",
+    "process": {
+        "terminal": true,
+        "user": { "uid": 0, "gid": 0 },
+        "args": ["/output/entrypoint.sh", "<IMAGE_NAME>"],
+        "env": [
+            "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+            "TERM=xterm"
+        ],
+        "cwd": "/",
+        "capabilities": {
+            "bounding": [],
+            "effective": [],
+            "inheritable": [],
+            "permitted": [],
+            "ambient": []
+        },
+        "rlimits": [
+            {
+                "type": "RLIMIT_NOFILE",
+                "hard": 262144,
+                "soft": 262144
+            }
+        ],
+        "noNewPrivileges": true
+    },
+    "root": {
+        "path": "<ORIGINAL_ROOT>",
+        "readonly": false
+    },
+    "hostname": "bootc-install",
+    "mounts": [
+        {
+            "type": "bind",
+            "source": "<PRIV_DIR>/root/crun-vm/bootc",
+            "destination": "/output",
+            "options": ["bind", "rprivate", "rw"]
+        },
+        {
+            "destination": "/proc",
+            "type": "proc",
+            "source": "proc"
+        },
+        {
+            "destination": "/dev/pts",
+            "type": "devpts",
+            "source": "devpts",
+            "options": [
+                "nosuid",
+                "noexec",
+                "newinstance",
+                "ptmxmode=0666",
+                "mode=0620",
+                "gid=5"
+            ]
+        }
+    ],
+    "linux": {
+        "namespaces": [
+            { "type": "pid" },
+            { "type": "network" },
+            { "type": "ipc" },
+            { "type": "uts" },
+            { "type": "cgroup" },
+            { "type": "mount" }
+        ],
+        "maskedPaths": [
+            "/proc/acpi",
+            "/proc/asound",
+            "/proc/kcore",
+            "/proc/keys",
+            "/proc/latency_stats",
+            "/proc/timer_list",
+            "/proc/timer_stats",
+            "/proc/sched_debug",
+            "/sys/firmware",
+            "/proc/scsi"
+        ],
+        "readonlyPaths": [
+            "/proc/bus",
+            "/proc/fs",
+            "/proc/irq",
+            "/proc/sys",
+            "/proc/sysrq-trigger"
+        ]
+    }
+}
diff --git a/embed/bootc/entrypoint.sh b/embed/bootc/entrypoint.sh
new file mode 100644
index 0000000..3798c5c
--- /dev/null
+++ b/embed/bootc/entrypoint.sh
@@ -0,0 +1,51 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+set -e
+
+image_name=$1
+
+# monkey-patch loopdev partition detection, given we're not running systemd
+# (bootc runs `udevadm settle` as a way to wait until loopdev partitions are
+# detected; we hijack that call and use partx to set up the partition devices)
+
+original_udevadm=$( which udevadm )
+
+mkdir -p /output/bin
+
+cat >/output/bin/udevadm <<EOF
+#!/bin/sh
+${original_udevadm@Q} "\$@" && partx --add /dev/loop0
+EOF
+
+chmod +x /output/bin/udevadm
+
+# default to an xfs root file system if there is no bootc config (some images
+# don't currently provide any, for instance quay.io/fedora/fedora-bootc:40)
+
+if ! find /usr/lib/bootc/install -mindepth 1 -maxdepth 1 | read; then
+    # /usr/lib/bootc/install is empty
+
+    cat >/usr/lib/bootc/install/00-crun-vm.toml <<EOF
+    [install.filesystem.root]
+    type = "xfs"
+EOF
+
+fi
+
+# build disk image using bootc-install
+
+PATH=/output/bin:$PATH bootc install to-disk \
+    --source-imgref docker-archive:/output/image.docker-archive \
+    --target-imgref "$image_name" \
+    --skip-fetch-check \
+    --generic-image \
+    --via-loopback \
+    --karg console=tty0 \
+    --karg console=ttyS0 \
+    --karg selinux=0 \
+    /output/image.raw
+
+# communicate success by creating a file, since krun always exits successfully
+
+touch /output/bootc-install-success
diff --git a/embed/bootc/prepare.sh b/embed/bootc/prepare.sh
new file mode 100644
index 0000000..7b90905
--- /dev/null
+++ b/embed/bootc/prepare.sh
@@ -0,0 +1,127 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+set -o errexit -o pipefail -o nounset
+
+engine=$1
+container_id=$2
+original_root=$3
+priv_dir=$4
+disk_size=$5
+
+__step() {
+    printf "\033[36m%s\033[0m\n" "$*"
+}
+
+bootc_dir=$priv_dir/root/crun-vm/bootc
+
+mkfifo "$bootc_dir/progress"
+exec > "$bootc_dir/progress" 2>&1
+
+# this blocks here until the named pipe above is opened by entrypoint.sh
+
+# get info about the container *image*
+
+image_info=$(
+    "$engine" container inspect \
+        --format '{{.Config.Image}}'$'\t''{{.Image}}' \
+        "$container_id"
+    )
+
+image_name=$( cut -f1 <<< "$image_info" )
+# image_name=${image_name#sha256:}
+
+image_id=$( cut -f2 <<< "$image_info" )
+
+# check if VM image is cached
+
+container_name=crun-vm-$container_id
+
+cache_image_label=containers.crun-vm.from=$image_id
+cache_image_id=$( "$engine" images --filter "label=$cache_image_label" --format '{{.Id}}' )
+
+if [[ -n "$cache_image_id" ]]; then
+
+    # retrieve VM image from cached containerdisk
+
+    __step "Retrieving cached VM image..."
+
+    trap '"$engine" rm --force "$container_name" >/dev/null 2>&1 || true' EXIT
+
+    "$engine" create --quiet --name "$container_name" "$cache_image_id" >/dev/null
+    "$engine" export "$container_name" | tar -C "$bootc_dir" -x image.qcow2
+    "$engine" rm "$container_name" >/dev/null 2>&1
+
+    trap '' EXIT
+
+else
+
+    __step "Converting $image_name into a VM image..."
+
+    # save container *image* as an archive
+
+    echo -n 'Preparing container image...'
+
+    "$engine" save --output "$bootc_dir/image.docker-archive" "$image_id" 2>&1 \
+        | sed -u 's/.*/./' \
+        | stdbuf -o0 tr -d '\n'
+
+    echo
+
+    # adjust krun config
+
+    __sed() {
+        sed -i "s|$1|$2|" "$bootc_dir/config.json"
+    }
+
+    __sed "<IMAGE_NAME>"    "$image_name"
+    __sed "<ORIGINAL_ROOT>" "$original_root"
+    __sed "<PRIV_DIR>"      "$priv_dir"
+
+    # run bootc-install under krun
+
+    if [[ -z "$disk_size" ]]; then
+        container_image_size=$(
+            "$engine" image inspect --format '{{.VirtualSize}}' "$image_id"
+            )
+
+        # use double the container image size to allow for in-place updates
+        disk_size=$(( container_image_size * 2 ))
+
+        # round up to 1 MiB
+        alignment=$(( 2**20 ))
+        disk_size=$(( (disk_size + alignment - 1) / alignment * alignment ))
+    fi
+
+    truncate --size "$disk_size" "$bootc_dir/image.raw"
+
+    trap 'krun delete --force "$container_name" >/dev/null 2>&1 || true' EXIT
+    krun run --config "$bootc_dir/config.json" "$container_name" </dev/ptmx
+    trap '' EXIT
+
+    [[ -e "$bootc_dir/bootc-install-success" ]]
+
+    # convert image to qcow2 to get a lower file length
+
+    qemu-img convert -f raw -O qcow2 "$bootc_dir/image.raw" "$bootc_dir/image.qcow2"
+    rm "$bootc_dir/image.raw"
+
+    # cache VM image file as containerdisk
+
+    __step "Caching VM image as a containerdisk..."
+
+    id=$(
+        "$engine" build --quiet --file - --label "$cache_image_label" "$bootc_dir" <<-'EOF'
+        FROM scratch
+        COPY image.qcow2 /
+        ENTRYPOINT ["no-entrypoint"]
+EOF
+    )
+
+    echo "Stored as untagged container image with ID $id"
+
+fi
+
+__step "Booting VM..."
+
+touch "$bootc_dir/success"
diff --git a/scripts/entrypoint.sh b/embed/entrypoint.sh
similarity index 87%
rename from scripts/entrypoint.sh
rename to embed/entrypoint.sh
index c0fffc0..18fa71e 100644
--- a/scripts/entrypoint.sh
+++ b/embed/entrypoint.sh
@@ -5,6 +5,8 @@ trap 'exit 143' SIGTERM
 
 set -o errexit -o pipefail -o nounset
 
+is_bootc_container=$1
+
 # clean up locks that may have been left around from the container being killed
 rm -fr /var/lock
 
@@ -53,6 +55,22 @@ virsh --connect "qemu+unix:///session?socket=$socket" "\$@"
 EOF
 chmod +x /crun-vm/virsh
 
+# wait until VM image is generated from bootable container (if applicable)
+
+if (( is_bootc_container == 1 )) && [[ ! -e /crun-vm/image/image ]]; then
+
+    fifo=/crun-vm/bootc/progress
+    while [[ ! -e "$fifo" ]]; do sleep 0.2; done
+    cat "$fifo"
+    rm "$fifo"
+
+    [[ -e /crun-vm/bootc/success ]]
+
+    mkdir -p /crun-vm/image
+    mv /crun-vm/bootc/image.qcow2 /crun-vm/image/image
+
+fi
+
 # launch VM
 
 function __bg_ensure_tty() {
diff --git a/scripts/exec.sh b/embed/exec.sh
similarity index 100%
rename from scripts/exec.sh
rename to embed/exec.sh
diff --git a/scripts/virtiofsd.sh b/embed/virtiofsd.sh
similarity index 100%
rename from scripts/virtiofsd.sh
rename to embed/virtiofsd.sh
diff --git a/plans/tests.fmf b/plans/tests.fmf
index c98e0fd..42db367 100644
--- a/plans/tests.fmf
+++ b/plans/tests.fmf
@@ -11,6 +11,7 @@ prepare:
       - cargo
       - coreutils
       - crun
+      - crun-krun
       - docker
       - genisoimage
       - grep
diff --git a/src/commands/create/custom_opts.rs b/src/commands/create/custom_opts.rs
index b62b820..d02a063 100644
--- a/src/commands/create/custom_opts.rs
+++ b/src/commands/create/custom_opts.rs
@@ -9,7 +9,7 @@ use clap::Parser;
 use lazy_static::lazy_static;
 use regex::Regex;
 
-use crate::commands::create::runtime_env::RuntimeEnv;
+use crate::commands::create::engine::Engine;
 
 #[derive(Clone, Debug)]
 pub struct Blockdev {
@@ -55,6 +55,9 @@ pub struct CustomOptions {
     #[clap(long, help = "Use system emulation rather than KVM")]
     pub emulated: bool,
 
+    #[clap(long)]
+    pub bootc_disk_size: Option<String>,
+
     #[clap(long)]
     pub cloud_init: Option<Utf8PathBuf>,
 
@@ -75,7 +78,7 @@ pub struct CustomOptions {
 }
 
 impl CustomOptions {
-    pub fn from_spec(spec: &oci_spec::runtime::Spec, env: RuntimeEnv) -> Result<Self> {
+    pub fn from_spec(spec: &oci_spec::runtime::Spec, engine: Engine) -> Result<Self> {
         let mut args: Vec<&String> = spec
             .process()
             .as_ref()
@@ -87,7 +90,14 @@ impl CustomOptions {
             .collect();
 
         if let Some(&first_arg) = args.first() {
-            if first_arg == "no-entrypoint" {
+            let ignore = [
+                "no-entrypoint",
+                "/sbin/init",
+                "/usr/sbin/init",
+                "/usr/local/sbin/init",
+            ];
+
+            if ignore.contains(&first_arg.as_str()) {
                 args.remove(0);
             }
         }
@@ -151,7 +161,7 @@ impl CustomOptions {
             ),
         );
 
-        if env == RuntimeEnv::Kubernetes {
+        if engine == Engine::Kubernetes {
             for blockdev in &mut options.blockdev {
                 blockdev.source = path_in_container_into_path_in_host(spec, &blockdev.source)?;
                 blockdev.target = path_in_container_into_path_in_host(spec, &blockdev.target)?;
diff --git a/src/commands/create/domain.rs b/src/commands/create/domain.rs
index 13964e3..f6d6bfd 100644
--- a/src/commands/create/domain.rs
+++ b/src/commands/create/domain.rs
@@ -58,11 +58,13 @@ fn generate(
         st(w, "memory", &[("unit", "b")], memory.as_str())?;
 
         s(w, "os", &[("firmware", "efi")], |w| {
-            let attrs = match ["x86", "x86_64"].contains(&env::consts::ARCH) {
-                true => [("machine", "q35")].as_slice(),
-                false => [].as_slice(), // use libvirt's default
-            };
-            st(w, "type", attrs, "hvm")?;
+            let guest_arch = vm_image_info.arch.as_deref().unwrap_or(env::consts::ARCH);
+
+            let mut attrs = vec![("arch", guest_arch)];
+            if ["x86", "x86_64"].contains(&guest_arch) {
+                attrs.push(("machine", "q35"));
+            }
+            st(w, "type", &attrs, "hvm")?;
 
             s(w, "firmware", &[], |w| {
                 se(w, "feature", &[("enabled", "no"), ("name", "secure-boot")])
diff --git a/src/commands/create/engine.rs b/src/commands/create/engine.rs
new file mode 100644
index 0000000..a989d53
--- /dev/null
+++ b/src/commands/create/engine.rs
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+use std::fs;
+use std::path::Path;
+
+use anyhow::{bail, Result};
+use camino::Utf8Path;
+use lazy_static::lazy_static;
+use regex::Regex;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Engine {
+    Podman,
+    Docker,
+    Kubernetes,
+}
+
+impl Engine {
+    pub fn command(self) -> Option<&'static str> {
+        match self {
+            Engine::Podman => Some("podman"),
+            Engine::Docker => Some("docker"),
+            Engine::Kubernetes => None,
+        }
+    }
+
+    pub fn detect(
+        container_id: &str,
+        bundle_path: &Utf8Path,
+        spec: &oci_spec::runtime::Spec,
+        original_root_path: impl AsRef<Utf8Path>,
+    ) -> Result<Engine> {
+        // TODO: Make this absolutely robust and secure. Probably require engine config to pass us
+        // an option specifying what engine is running crun-vm.
+
+        // check if we're under CRI-O under Kubernetes
+
+        {
+            let has_kubernetes_secrets_dir = spec.mounts().iter().flatten().any(|m| {
+                m.destination()
+                    .starts_with("/var/run/secrets/kubernetes.io")
+            });
+
+            let has_kubernetes_managed_etc_hosts = spec
+                .mounts()
+                .iter()
+                .flatten()
+                .filter(|m| m.destination() == Utf8Path::new("/etc/hosts"))
+                .flat_map(|m| m.source())
+                .next()
+                .map(fs::read_to_string)
+                .transpose()?
+                .and_then(|hosts| hosts.lines().next().map(|line| line.to_string()))
+                .map(|line| line.contains("Kubernetes-managed hosts file"))
+                .unwrap_or(false);
+
+            if has_kubernetes_secrets_dir || has_kubernetes_managed_etc_hosts {
+                return Ok(Engine::Kubernetes);
+            }
+        }
+
+        // check if we're under Docker
+
+        {
+            let has_dot_dockerenv_file = original_root_path
+                .as_ref()
+                .join(".dockerenv")
+                .try_exists()?;
+
+            if has_dot_dockerenv_file {
+                return Ok(Engine::Docker);
+            }
+        }
+
+        // check if we're under Podman
+
+        {
+            let has_mount_on = |p| {
+                spec.mounts()
+                    .iter()
+                    .flatten()
+                    .any(|m| m.destination() == Path::new(p))
+            };
+
+            let has_dot_containerenv_file =
+                has_mount_on("/run/.containerenv") || has_mount_on("/var/run/.containerenv");
+
+            lazy_static! {
+                static ref BUNDLE_PATH_PATTERN: Regex =
+                    Regex::new(r"/overlay-containers/([^/]+)/userdata$").unwrap();
+            }
+
+            let is_podman_bundle_path = match BUNDLE_PATH_PATTERN.captures(bundle_path.as_str()) {
+                Some(captures) => &captures[1] == container_id,
+                None => false,
+            };
+
+            if has_dot_containerenv_file && is_podman_bundle_path {
+                return Ok(Engine::Podman);
+            }
+        }
+
+        // unknown engine
+
+        bail!("could not identify container engine; crun-vm current only supports Podman, Docker, and Kubernetes");
+    }
+}
diff --git a/src/commands/create/mod.rs b/src/commands/create/mod.rs
index dcc557d..2370a03 100644
--- a/src/commands/create/mod.rs
+++ b/src/commands/create/mod.rs
@@ -2,28 +2,31 @@
 
 mod custom_opts;
 mod domain;
+mod engine;
 mod first_boot;
-mod runtime_env;
 
 use std::ffi::OsStr;
 use std::fs::{self, File, Permissions};
 use std::io::ErrorKind;
 use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
 use std::path::Path;
-use std::process::Command;
+use std::process::{Command, Stdio};
 
 use anyhow::{anyhow, bail, ensure, Context, Result};
 use camino::{Utf8Path, Utf8PathBuf};
+use lazy_static::lazy_static;
 use nix::sys::stat::{major, makedev, minor, mknod, Mode, SFlag};
+use regex::Regex;
 use rust_embed::RustEmbed;
 
 use crate::commands::create::custom_opts::CustomOptions;
 use crate::commands::create::domain::set_up_libvirt_domain_xml;
+use crate::commands::create::engine::Engine;
 use crate::commands::create::first_boot::FirstBootConfig;
-use crate::commands::create::runtime_env::RuntimeEnv;
 use crate::util::{
     bind_mount_dir_with_different_context, bind_mount_file, create_overlay_vm_image, crun,
-    find_single_file_in_dirs, is_mountpoint, set_file_context, SpecExt, VmImageInfo,
+    find_single_file_in_dirs, fix_selinux_label, is_mountpoint, set_file_context, SpecExt,
+    VmImageInfo,
 };
 
 pub fn create(args: &liboci_cli::Create, raw_args: &[impl AsRef<OsStr>]) -> Result<()> {
@@ -31,28 +34,13 @@ pub fn create(args: &liboci_cli::Create, raw_args: &[impl AsRef<OsStr>]) -> Resu
     let config_path = bundle_path.join("config.json");
 
     let mut spec = oci_spec::runtime::Spec::load(&config_path)?;
-    let original_root_path: Utf8PathBuf = spec.root_path()?.canonicalize()?.try_into()?; // ensure absolute
-
-    if let Some(process) = spec.process().as_ref() {
-        if let Some(capabilities) = process.capabilities().as_ref() {
-            fn any_is_cap_sys_admin(caps: &Option<oci_spec::runtime::Capabilities>) -> bool {
-                caps.as_ref()
-                    .is_some_and(|set| set.contains(&oci_spec::runtime::Capability::SysAdmin))
-            }
+    ensure_unprivileged(&spec)?;
 
-            ensure!(
-                !any_is_cap_sys_admin(capabilities.bounding())
-                    && !any_is_cap_sys_admin(capabilities.effective())
-                    && !any_is_cap_sys_admin(capabilities.inheritable())
-                    && !any_is_cap_sys_admin(capabilities.permitted())
-                    && !any_is_cap_sys_admin(capabilities.ambient()),
-                "crun-vm is incompatible with privileged containers"
-            );
-        }
-    }
+    let original_root_path: Utf8PathBuf = spec.root_path()?.canonicalize()?.try_into()?; // ensure absolute
 
-    let runtime_env = RuntimeEnv::current(&spec, &original_root_path)?;
-    let custom_options = CustomOptions::from_spec(&spec, runtime_env)?;
+    let engine = Engine::detect(&args.container_id, bundle_path, &spec, &original_root_path)?;
+    let custom_options = CustomOptions::from_spec(&spec, engine)?;
+    let is_bootc_container = is_bootc_container(&original_root_path, &custom_options, engine)?;
 
     // We include container_id in our paths to ensure no overlap with the user container's contents.
     let priv_dir_path = original_root_path.join(format!("crun-vm-{}", args.container_id));
@@ -65,7 +53,13 @@ pub fn create(args: &liboci_cli::Create, raw_args: &[impl AsRef<OsStr>]) -> Resu
         set_file_context(&priv_dir_path, context)?;
     }
 
-    set_up_container_root(&mut spec, &priv_dir_path, &custom_options)?;
+    set_up_container_root(
+        &mut spec,
+        &priv_dir_path,
+        &custom_options,
+        is_bootc_container,
+    )?;
+
     let is_first_create = is_first_create(&spec)?;
 
     let base_vm_image_info = set_up_vm_image(
@@ -74,6 +68,7 @@ pub fn create(args: &liboci_cli::Create, raw_args: &[impl AsRef<OsStr>]) -> Resu
         &priv_dir_path,
         &custom_options,
         is_first_create,
+        is_bootc_container,
     )?;
 
     let mut mounts = Mounts::default();
@@ -87,7 +82,7 @@ pub fn create(args: &liboci_cli::Create, raw_args: &[impl AsRef<OsStr>]) -> Resu
     let ssh_pub_key = set_up_ssh_key_pair(
         &mut spec,
         &custom_options,
-        runtime_env,
+        engine,
         &priv_dir_path,
         is_first_create,
     )?;
@@ -104,9 +99,97 @@ pub fn create(args: &liboci_cli::Create, raw_args: &[impl AsRef<OsStr>]) -> Resu
 
     crun(raw_args)?; // actually create container
 
+    if is_first_create && is_bootc_container {
+        // We want to ask podman what our image name is, so we can give it to bootc-install, but we
+        // can't wait synchronously for a response since podman hangs until this create command
+        // completes. We then want to run bootc-install under krun, which already isolates the
+        // workload and so can be run outside of our container. We thus launch a process that
+        // asynchronously performs these steps, and share its progress and output with out
+        // container's entrypoint through a named pipe.
+        //
+        // Note that this process blocks until our container's entrypoint actually starts running,
+        // thus after the "start" OCI runtime command is called.
+
+        let bootc_dir = priv_dir_path.join("root/crun-vm/bootc");
+        fs::create_dir_all(&bootc_dir)?;
+
+        std::process::Command::new(bootc_dir.join("prepare.sh"))
+            .arg(engine.command().unwrap())
+            .arg(&args.container_id)
+            .arg(&original_root_path)
+            .arg(&priv_dir_path)
+            .arg(custom_options.bootc_disk_size.unwrap_or_default())
+            .stdin(Stdio::null())
+            .stdout(Stdio::null())
+            .stderr(Stdio::null())
+            .spawn()?;
+    }
+
     Ok(())
 }
 
+fn ensure_unprivileged(spec: &oci_spec::runtime::Spec) -> Result<()> {
+    if let Some(process) = spec.process().as_ref() {
+        if let Some(capabilities) = process.capabilities().as_ref() {
+            fn any_is_cap_sys_admin(caps: &Option<oci_spec::runtime::Capabilities>) -> bool {
+                caps.as_ref()
+                    .is_some_and(|set| set.contains(&oci_spec::runtime::Capability::SysAdmin))
+            }
+
+            ensure!(
+                !any_is_cap_sys_admin(capabilities.bounding())
+                    && !any_is_cap_sys_admin(capabilities.effective())
+                    && !any_is_cap_sys_admin(capabilities.inheritable())
+                    && !any_is_cap_sys_admin(capabilities.permitted())
+                    && !any_is_cap_sys_admin(capabilities.ambient()),
+                "crun-vm is incompatible with privileged containers"
+            );
+        }
+    }
+
+    Ok(())
+}
+
+fn is_bootc_container(
+    original_root_path: &Utf8Path,
+    custom_options: &CustomOptions,
+    engine: Engine,
+) -> Result<bool> {
+    let is_bootc_container = original_root_path.join("usr/lib/bootc/install").is_dir();
+
+    ensure!(
+        !is_bootc_container || engine == Engine::Podman || engine == Engine::Docker,
+        "bootc containers are only supported with Podman and Docker"
+    );
+
+    ensure!(
+        !is_bootc_container || !custom_options.emulated,
+        "--emulated is incompatible with bootable containers"
+    );
+
+    if let Some(size) = &custom_options.bootc_disk_size {
+        lazy_static! {
+            static ref SIZE_PATTERN: Regex = Regex::new(r"^[0-9]+[KMGT]?$").unwrap();
+        }
+
+        ensure!(
+            SIZE_PATTERN.is_match(size),
+            concat!(
+                "--bootc-disk-size value must be a number followed by an optional suffix K",
+                " (kilobyte, 1024), M (megabyte, 1024k), G (gigabyte, 1024M), or T (terabyte,",
+                " 1024G)",
+            )
+        );
+
+        ensure!(
+            is_bootc_container,
+            "--bootc-disk-size only applies to bootable containers"
+        );
+    }
+
+    Ok(is_bootc_container)
+}
+
 fn is_first_create(spec: &oci_spec::runtime::Spec) -> Result<bool> {
     let path = spec.root_path()?.join("crun-vm/create-ran");
 
@@ -128,6 +211,7 @@ fn set_up_container_root(
     spec: &mut oci_spec::runtime::Spec,
     priv_dir_path: &Utf8Path,
     custom_options: &CustomOptions,
+    is_bootc_container: bool,
 ) -> Result<()> {
     let new_root_path = priv_dir_path.join("root");
     fs::create_dir_all(&new_root_path)?;
@@ -147,19 +231,22 @@ fn set_up_container_root(
             .unwrap(),
     ));
 
-    // set up container scripts
+    // set up container files
 
     #[derive(RustEmbed)]
-    #[folder = "scripts/"]
-    struct Scripts;
+    #[folder = "embed/"]
+    struct Embed;
 
-    for path in Scripts::iter() {
+    for path in Embed::iter() {
         let path_in_host = new_root_path.join("crun-vm").join(path.as_ref());
         fs::create_dir_all(path_in_host.parent().unwrap())?;
 
-        let file = Scripts::get(&path).unwrap();
+        let file = Embed::get(&path).unwrap();
         fs::write(&path_in_host, file.data)?;
-        fs::set_permissions(&path_in_host, Permissions::from_mode(0o755))?;
+
+        let is_script = path.as_ref().ends_with(".sh");
+        let mode = if is_script { 0o755 } else { 0o644 };
+        fs::set_permissions(&path_in_host, Permissions::from_mode(mode))?;
     }
 
     // configure container entrypoint
@@ -169,14 +256,19 @@ fn set_up_container_root(
     } else if custom_options.print_config_json {
         vec!["cat", "/crun-vm/config.json"]
     } else {
-        vec!["/crun-vm/entrypoint.sh"]
+        let arg = if is_bootc_container { "1" } else { "0" };
+        vec!["/crun-vm/entrypoint.sh", arg]
     };
 
     spec.set_process({
         let mut process = spec.process().clone().unwrap();
+
         process.set_cwd(".".into());
         process.set_command_line(None);
         process.set_args(Some(command.into_iter().map(String::from).collect()));
+
+        fix_selinux_label(&mut process);
+
         Some(process)
     });
 
@@ -189,7 +281,21 @@ fn set_up_vm_image(
     priv_dir_path: &Utf8Path,
     custom_options: &CustomOptions,
     is_first_create: bool,
+    is_bootc_container: bool,
 ) -> Result<VmImageInfo> {
+    let mirror_vm_image_path_in_container = Utf8PathBuf::from("/crun-vm/image/image");
+    let mirror_vm_image_path_in_host = spec.root_path()?.join("crun-vm/image/image");
+
+    if is_bootc_container {
+        // the image will be generated later
+        return Ok(VmImageInfo {
+            path: mirror_vm_image_path_in_container,
+            arch: None,
+            size: 0,
+            format: "qcow2".to_string(),
+        });
+    }
+
     // where inside the container to look for the VM image
     const VM_IMAGE_SEARCH_PATHS: [&str; 2] = ["./", "disk/"];
 
@@ -210,12 +316,9 @@ fn set_up_vm_image(
     fs::create_dir_all(&image_dir_path)?;
 
     if !image_dir_path.join("image").try_exists()? {
-        fs::hard_link(vm_image_path_in_host, image_dir_path.join("image"))?;
+        fs::hard_link(&vm_image_path_in_host, image_dir_path.join("image"))?;
     }
 
-    let mirror_vm_image_path_in_container = Utf8PathBuf::from("/crun-vm/image/image");
-    let mirror_vm_image_path_in_host = spec.root_path()?.join("crun-vm/image/image");
-
     if custom_options.persistent {
         // Mount overlayfs to expose the user's VM image file with a different SELinux context so we
         // can always access it, using the file's parent as the upperdir so that writes still
@@ -225,7 +328,7 @@ fn set_up_vm_image(
             bind_mount_dir_with_different_context(
                 image_dir_path,
                 mirror_vm_image_path_in_host.parent().unwrap(),
-                priv_dir_path.join("scratch"),
+                priv_dir_path.join("scratch-image"),
                 spec.mount_label(),
                 false,
             )?;
@@ -236,7 +339,8 @@ fn set_up_vm_image(
 
         bind_mount_file(&mirror_vm_image_path_in_host, &mirror_vm_image_path_in_host)?;
 
-        let mut vm_image_info = VmImageInfo::of(&mirror_vm_image_path_in_host)?;
+        let mut vm_image_info =
+            VmImageInfo::of(&mirror_vm_image_path_in_host, custom_options.emulated)?;
         vm_image_info.path = mirror_vm_image_path_in_container;
 
         Ok(vm_image_info)
@@ -248,7 +352,7 @@ fn set_up_vm_image(
             bind_mount_dir_with_different_context(
                 image_dir_path,
                 mirror_vm_image_path_in_host.parent().unwrap(),
-                priv_dir_path.join("scratch"),
+                priv_dir_path.join("scratch-image"),
                 spec.mount_label(),
                 true,
             )?;
@@ -264,7 +368,8 @@ fn set_up_vm_image(
         let overlay_vm_image_path_in_container =
             Utf8Path::new("/").join(overlay_vm_image_path_in_container);
 
-        let mut base_vm_image_info = VmImageInfo::of(&mirror_vm_image_path_in_host)?;
+        let mut base_vm_image_info =
+            VmImageInfo::of(&vm_image_path_in_host, custom_options.emulated)?;
         base_vm_image_info.path = mirror_vm_image_path_in_container;
 
         if is_first_create {
@@ -273,6 +378,7 @@ fn set_up_vm_image(
 
         Ok(VmImageInfo {
             path: Utf8Path::new("/").join(overlay_vm_image_path_in_container),
+            arch: base_vm_image_info.arch,
             size: base_vm_image_info.size,
             format: "qcow2".to_string(),
         })
@@ -575,7 +681,7 @@ fn set_up_security(spec: &mut oci_spec::runtime::Spec) {
     // TODO: This doesn't seem reasonable at all. Should we just force users to use a different
     // seccomp profile? Should passt provide the option to bypass a lot of the isolation that it
     // does, given we're already in a container *and* under a seccomp profile?
-    spec.linux_seccomp_syscalls_push(
+    spec.linux_seccomp_syscalls_push_front(
         oci_spec::runtime::LinuxSyscallBuilder::default()
             .names(["mount", "pivot_root", "umount2", "unshare"].map(String::from))
             .action(oci_spec::runtime::LinuxSeccompAction::ScmpActAllow)
@@ -623,7 +729,7 @@ fn set_up_first_boot_config(
 fn set_up_ssh_key_pair(
     spec: &mut oci_spec::runtime::Spec,
     custom_options: &CustomOptions,
-    env: RuntimeEnv,
+    engine: Engine,
     priv_dir_path: &Utf8Path,
     is_first_create: bool,
 ) -> Result<String> {
@@ -641,7 +747,7 @@ fn set_up_ssh_key_pair(
     //   - We're not running under Kubernetes (where there isn't a "host user"); and
     //   - They have a key pair.
     let use_user_key_pair = !custom_options.random_ssh_key_pair
-        && env == RuntimeEnv::Other
+        && engine == Engine::Podman
         && user_ssh_dir.join("id_rsa.pub").is_file()
         && user_ssh_dir.join("id_rsa").is_file();
 
diff --git a/src/commands/create/runtime_env.rs b/src/commands/create/runtime_env.rs
deleted file mode 100644
index 727802b..0000000
--- a/src/commands/create/runtime_env.rs
+++ /dev/null
@@ -1,51 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-use std::fs;
-
-use anyhow::Result;
-use camino::Utf8Path;
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RuntimeEnv {
-    Docker,
-    Kubernetes,
-    Other,
-}
-
-impl RuntimeEnv {
-    pub fn current(
-        spec: &oci_spec::runtime::Spec,
-        original_root_path: impl AsRef<Utf8Path>,
-    ) -> Result<RuntimeEnv> {
-        let has_kubernetes_secrets_dir = spec.mounts().iter().flatten().any(|m| {
-            m.destination()
-                .starts_with("/var/run/secrets/kubernetes.io")
-        });
-
-        let has_kubernetes_managed_etc_hosts = spec
-            .mounts()
-            .iter()
-            .flatten()
-            .filter(|m| m.destination() == Utf8Path::new("/etc/hosts"))
-            .flat_map(|m| m.source())
-            .next()
-            .map(fs::read_to_string)
-            .transpose()?
-            .and_then(|hosts| hosts.lines().next().map(|line| line.to_string()))
-            .map(|line| line.contains("Kubernetes-managed hosts file"))
-            .unwrap_or(false);
-
-        let has_dockerenv_dot_file = original_root_path
-            .as_ref()
-            .join(".dockerenv")
-            .try_exists()?;
-
-        if has_kubernetes_secrets_dir || has_kubernetes_managed_etc_hosts {
-            Ok(RuntimeEnv::Kubernetes)
-        } else if has_dockerenv_dot_file {
-            Ok(RuntimeEnv::Docker)
-        } else {
-            Ok(RuntimeEnv::Other)
-        }
-    }
-}
diff --git a/src/commands/exec.rs b/src/commands/exec.rs
index 6f983a6..c7f035b 100644
--- a/src/commands/exec.rs
+++ b/src/commands/exec.rs
@@ -8,11 +8,13 @@ use std::io::{BufReader, BufWriter};
 use anyhow::{bail, Result};
 use clap::Parser;
 
-use crate::util::crun;
+use crate::util::{crun, fix_selinux_label};
 
 pub fn exec(args: &liboci_cli::Exec, raw_args: &[impl AsRef<OsStr>]) -> Result<()> {
     assert!(args.command.is_empty());
 
+    // load exec process config
+
     let process_config_path = args.process.as_ref().expect("process config");
     let mut process: oci_spec::runtime::Process =
         serde_json::from_reader(File::open(process_config_path).map(BufReader::new)?)?;
@@ -22,11 +24,17 @@ pub fn exec(args: &liboci_cli::Exec, raw_args: &[impl AsRef<OsStr>]) -> Result<(
     let new_command = build_command(command)?;
     process.set_args(Some(new_command));
 
+    fix_selinux_label(&mut process);
+
+    // store modified exec process config
+
     serde_json::to_writer(
         File::create(process_config_path).map(BufWriter::new)?,
         &process,
     )?;
 
+    // actually exec
+
     crun(raw_args)?;
 
     Ok(())
diff --git a/src/util.rs b/src/util.rs
index 107e9d6..2a75789 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -1,26 +1,46 @@
 // SPDX-License-Identifier: GPL-2.0-or-later
 
+use std::env;
 use std::ffi::{c_char, CString, OsStr};
 use std::fs::{self, OpenOptions, Permissions};
-use std::io::{self, ErrorKind};
+use std::io::{self, ErrorKind, Write};
 use std::os::unix::ffi::OsStrExt;
 use std::os::unix::fs::{MetadataExt, PermissionsExt};
 use std::process::{Command, Stdio};
+use std::str;
 
 use anyhow::{anyhow, bail, ensure, Result};
 use camino::{Utf8Path, Utf8PathBuf};
 use nix::mount::{MntFlags, MsFlags};
 use serde::Deserialize;
 
+// When the container image's entrypoint is /sbin/init or similar, Podman gives the entrypoint (and
+// exec entrypoint) process an SELinux label of, for instance:
+//
+//     system_u:system_r:container_init_t:s0:c276,c638
+//
+// However, we are going to change our entrypoint to something else, so we need to use the
+// "standard" label that Podman otherwise gives, which in this case would be:
+//
+//     system_u:system_r:container_t:s0:c276,c638
+//
+// This function performs that mapping.
+pub fn fix_selinux_label(process: &mut oci_spec::runtime::Process) {
+    if let Some(label) = process.selinux_label() {
+        let new_label = label.replace("container_init_t", "container_t");
+        process.set_selinux_label(Some(new_label));
+    }
+}
+
 pub fn set_file_context(path: impl AsRef<Utf8Path>, context: &str) -> Result<()> {
     extern "C" {
-        fn setfilecon(path: *const c_char, con: *const c_char) -> i32;
+        fn lsetfilecon(path: *const c_char, con: *const c_char) -> i32;
     }
 
     let path = CString::new(path.as_ref().as_os_str().as_bytes())?;
     let context = CString::new(context.as_bytes())?;
 
-    if unsafe { setfilecon(path.as_ptr(), context.as_ptr()) } != 0 {
+    if unsafe { lsetfilecon(path.as_ptr(), context.as_ptr()) } != 0 {
         return Err(io::Error::last_os_error().into());
     }
 
@@ -179,7 +199,7 @@ pub trait SpecExt {
         linux_device_cgroup: oci_spec::runtime::LinuxDeviceCgroup,
     );
     fn process_capabilities_insert_beip(&mut self, capability: oci_spec::runtime::Capability);
-    fn linux_seccomp_syscalls_push(&mut self, linux_syscall: oci_spec::runtime::LinuxSyscall);
+    fn linux_seccomp_syscalls_push_front(&mut self, linux_syscall: oci_spec::runtime::LinuxSyscall);
 }
 
 impl SpecExt for oci_spec::runtime::Spec {
@@ -257,7 +277,10 @@ impl SpecExt for oci_spec::runtime::Spec {
         });
     }
 
-    fn linux_seccomp_syscalls_push(&mut self, linux_syscall: oci_spec::runtime::LinuxSyscall) {
+    fn linux_seccomp_syscalls_push_front(
+        &mut self,
+        linux_syscall: oci_spec::runtime::LinuxSyscall,
+    ) {
         self.set_linux({
             let mut linux = self.linux().clone().expect("linux config");
             linux.set_seccomp({
@@ -265,7 +288,7 @@ impl SpecExt for oci_spec::runtime::Spec {
                 if let Some(seccomp) = &mut seccomp {
                     seccomp.set_syscalls({
                         let mut syscalls = seccomp.syscalls().clone().unwrap_or_default();
-                        syscalls.push(linux_syscall);
+                        syscalls.insert(0, linux_syscall);
                         Some(syscalls)
                     });
                 }
@@ -314,6 +337,9 @@ pub struct VmImageInfo {
     #[serde(skip)]
     pub path: Utf8PathBuf,
 
+    #[serde(skip)]
+    pub arch: Option<String>,
+
     #[serde(rename = "virtual-size")]
     pub size: u64,
 
@@ -321,7 +347,7 @@ pub struct VmImageInfo {
 }
 
 impl VmImageInfo {
-    pub fn of(vm_image_path: impl AsRef<Utf8Path>) -> Result<VmImageInfo> {
+    pub fn of(vm_image_path: impl AsRef<Utf8Path>, identify_arch: bool) -> Result<VmImageInfo> {
         let vm_image_path = vm_image_path.as_ref().to_path_buf();
 
         let output = Command::new("qemu-img")
@@ -340,6 +366,12 @@ impl VmImageInfo {
         let mut info: VmImageInfo = serde_json::from_slice(&output.stdout)?;
         info.path = vm_image_path;
 
+        if identify_arch {
+            info.arch = identify_image_arch(&info.path)?
+                .ok_or_else(|| anyhow!("Could not identify VM image architecture"))?
+                .into();
+        }
+
         Ok(info)
     }
 }
@@ -371,6 +403,62 @@ pub fn create_overlay_vm_image(
     Ok(())
 }
 
+pub fn identify_image_arch(image_path: impl AsRef<Utf8Path>) -> Result<Option<String>> {
+    let xml = virt_inspector([
+        "--add",
+        image_path.as_ref().as_str(),
+        "--no-applications",
+        "--no-icon",
+    ])?;
+
+    xpath(&xml, "string(//arch)")
+}
+
+fn virt_inspector(args: impl IntoIterator<Item = impl AsRef<OsStr>>) -> Result<String> {
+    let cache_dir = format!("/var/tmp/crun-vm-{}", env::var("_CONTAINERS_ROOTLESS_UID")?);
+    fs::create_dir_all(&cache_dir)?;
+
+    let output = Command::new("virt-inspector")
+        .args(args)
+        .env("LIBGUESTFS_BACKEND", "direct")
+        .env("LIBGUESTFS_CACHEDIR", &cache_dir)
+        .output()?;
+
+    ensure!(
+        output.status.success(),
+        "virt-inspector failed: {}",
+        String::from_utf8_lossy(&output.stderr),
+    );
+
+    Ok(String::from_utf8(output.stdout)?)
+}
+
+fn xpath(xml: &str, path: &str) -> Result<Option<String>> {
+    let mut child = Command::new("virt-inspector")
+        .arg("--xpath")
+        .arg(path)
+        .stdin(Stdio::piped())
+        .spawn()?;
+
+    child.stdin.take().unwrap().write_all(xml.as_bytes())?;
+
+    let output = child.wait_with_output()?;
+
+    ensure!(
+        output.status.success(),
+        "virt-inspector --xpath failed: {}",
+        String::from_utf8_lossy(&output.stderr),
+    );
+
+    let result = String::from_utf8(output.stdout)?;
+
+    if result.is_empty() {
+        Ok(None)
+    } else {
+        Ok(Some(result))
+    }
+}
+
 /// Run `crun`.
 ///
 /// `crun` will inherit this process' standard streams.
diff --git a/tests/env.sh b/tests/env.sh
index f6ed1d2..ee9a874 100755
--- a/tests/env.sh
+++ b/tests/env.sh
@@ -13,18 +13,21 @@ declare -A TEST_IMAGES
 TEST_IMAGES=(
     [fedora]=quay.io/containerdisks/fedora:40          # uses cloud-init
     [coreos]=quay.io/crun-vm/example-fedora-coreos:40  # uses Ignition
+    [fedora-bootc]=quay.io/fedora/fedora-bootc:40      # bootable container
 )
 
 declare -A TEST_IMAGES_DEFAULT_USER
 TEST_IMAGES_DEFAULT_USER=(
     [fedora]=fedora
     [coreos]=core
+    [fedora-bootc]=cloud-user
 )
 
 declare -A TEST_IMAGES_DEFAULT_USER_HOME
 TEST_IMAGES_DEFAULT_USER_HOME=(
     [fedora]=/home/fedora
     [coreos]=/var/home/core
+    [fedora-bootc]=/var/home/cloud-user
 )
 
 __bad_usage() {
@@ -140,12 +143,12 @@ build)
 
     # expand base image
 
-    __log_and_run qemu-img create -f qcow2 "$temp_dir/resized-image.qcow2" 20G
+    __log_and_run qemu-img create -f qcow2 "$temp_dir/image.qcow2" 50G
     __log_and_run virt-resize \
         --quiet \
         --expand /dev/sda4 \
         "$temp_dir/image" \
-        "$temp_dir/resized-image.qcow2"
+        "$temp_dir/image.qcow2"
 
     rm "$temp_dir/image"
 
@@ -179,6 +182,7 @@ build)
         bash \
         coreutils \
         crun \
+        crun-krun \
         docker \
         genisoimage \
         grep \
@@ -210,17 +214,12 @@ build)
     __log_and_run podman wait --ignore "$container_name-build"
     __extra_cleanup() { :; }
 
-    __log_and_run virt-sparsify \
-        --quiet \
-        "$temp_dir/resized-image.qcow2" \
-        "$temp_dir/final-image.qcow2"
-
-    rm "$temp_dir/resized-image.qcow2"
+    __log_and_run virt-sparsify --quiet --in-place "$temp_dir/image.qcow2"
 
     # package new image file
 
     __log_and_run "$( __rel "$repo_root/util/package-vm-image.sh" )" \
-        "$temp_dir/final-image.qcow2" \
+        "$temp_dir/image.qcow2" \
         "$env_image"
 
     __big_log 33 'Done.'
@@ -393,6 +392,7 @@ run)
                 }
                 TEMP_DIR=~/$label.temp
                 UTIL_DIR=~/$label.util
+                TEST_ID=$label
                 ENGINE=$engine
                 export RUST_BACKTRACE=1 RUST_LIB_BACKTRACE=1
                 $( cat "$t" )\
diff --git a/tests/t/bootc-disk-size.sh b/tests/t/bootc-disk-size.sh
new file mode 100644
index 0000000..157cf03
--- /dev/null
+++ b/tests/t/bootc-disk-size.sh
@@ -0,0 +1,23 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+image="${TEST_IMAGES[fedora-bootc]}"
+user="${TEST_IMAGES_DEFAULT_USER[fedora-bootc]}"
+
+__run() {
+    __engine run --detach --name "$TEST_ID" "$image" --bootc-disk-size "$1"
+}
+
+! RUST_LIB_BACKTRACE=0 __run 0
+__engine rm "$TEST_ID"
+
+for size in 1K 1M; do
+    __run "$size"
+    ! __engine exec "$TEST_ID" --as "$user"
+    __engine rm --force "$TEST_ID"
+done
+
+for size in 1G 1T 1024T; do
+    __run "$size"
+    __engine exec "$TEST_ID" --as "$user"
+    __engine rm --force "$TEST_ID"
+done
diff --git a/tests/t/bootc-rootfs.sh b/tests/t/bootc-rootfs.sh
new file mode 100644
index 0000000..f5a1700
--- /dev/null
+++ b/tests/t/bootc-rootfs.sh
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+"$UTIL_DIR/extract-vm-image.sh" "${TEST_IMAGES[fedora-bootc]}" "$TEMP_DIR/image"
+
+__run() {
+    __engine run --rm --detach --name "$TEST_ID" "$@" --rootfs "$TEMP_DIR"
+}
+
+! __run
+! __run --persistent
diff --git a/tests/t/cloud-init.sh b/tests/t/cloud-init.sh
index 6ea51dd..69575d2 100644
--- a/tests/t/cloud-init.sh
+++ b/tests/t/cloud-init.sh
@@ -1,30 +1,36 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 
-image="${TEST_IMAGES[fedora]}"
-user="${TEST_IMAGES_DEFAULT_USER[fedora]}"
-home="${TEST_IMAGES_DEFAULT_USER_HOME[fedora]}"
-
-cat >"$TEMP_DIR/user-data" <<EOF
-#cloud-config
-write_files:
-  - path: $home/file
-    content: |
-      hello
+for os in fedora fedora-bootc; do
+
+    image="${TEST_IMAGES[$os]}"
+    user="${TEST_IMAGES_DEFAULT_USER[$os]}"
+    home="${TEST_IMAGES_DEFAULT_USER_HOME[$os]}"
+
+    cat >"$TEMP_DIR/user-data" <<-EOF
+    #cloud-config
+    write_files:
+      - path: $home/file
+        content: |
+          hello
 EOF
 
-cat >"$TEMP_DIR/meta-data" <<EOF
+    cat >"$TEMP_DIR/meta-data" <<-EOF
 EOF
 
-__engine run \
-    --rm --detach \
-    --name cloud-init \
-    "$image" \
-    --cloud-init "$TEMP_DIR"
+    __engine run \
+        --rm --detach \
+        --name "$TEST_ID" \
+        "$image" \
+        --cloud-init "$TEMP_DIR"
+
+    __test() {
+        __engine exec "$TEST_ID" --as "$user" "cmp $home/file <<< hello"
+    }
+
+    __test
+    __engine restart "$TEST_ID"
+    __test
 
-__test() {
-    __engine exec cloud-init --as "$user" "cmp $home/file <<< hello"
-}
+    __engine stop "$TEST_ID"
 
-__test
-__engine restart cloud-init
-__test
+done
diff --git a/tests/t/emulated.sh b/tests/t/emulated.sh
index 0b6675a..7cffb4b 100644
--- a/tests/t/emulated.sh
+++ b/tests/t/emulated.sh
@@ -1,4 +1,4 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 
-__engine run --detach --name emulated "${TEST_IMAGES[fedora]}" --emulated
-__engine exec emulated --as fedora
+__engine run --detach --name "$TEST_ID" "${TEST_IMAGES[fedora]}" --emulated
+__engine exec "$TEST_ID" --as fedora
diff --git a/tests/t/hostname.sh b/tests/t/hostname.sh
index 3b1039c..9879af2 100644
--- a/tests/t/hostname.sh
+++ b/tests/t/hostname.sh
@@ -1,42 +1,42 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 
-for os in fedora coreos; do
+for os in "${!TEST_IMAGES[@]}"; do
 
     image="${TEST_IMAGES[$os]}"
     user="${TEST_IMAGES_DEFAULT_USER[$os]}"
 
     # default hostname
 
-    id=$( __engine run --rm --detach --name "hostname-$os-default" "$image" )
+    id=$( __engine run --rm --detach --name "$TEST_ID-$os-default" "$image" )
 
     __test() {
-        __engine exec "hostname-$os-default" --as "$user" \
+        __engine exec "$TEST_ID-$os-default" --as "$user" \
             "set -x && [[ \$( hostname ) == ${id::12} ]]"
     }
 
     __test
-    __engine restart "hostname-$os-default"
+    __engine restart "$TEST_ID-$os-default"
     __test
 
-    __engine stop --time 0 "hostname-$os-default"
+    __engine stop --time 0 "$TEST_ID-$os-default"
 
     # custom hostname
 
     __engine run \
         --rm --detach \
-        --name "hostname-$os-custom" \
+        --name "$TEST_ID-$os-custom" \
         --hostname my-test-vm \
         "$image"
 
     __test() {
-        __engine exec "hostname-$os-custom" --as "$user" \
+        __engine exec "$TEST_ID-$os-custom" --as "$user" \
             "set -x && [[ \$( hostname ) == my-test-vm ]]"
     }
 
     __test
-    __engine restart "hostname-$os-custom"
+    __engine restart "$TEST_ID-$os-custom"
     __test
 
-    __engine stop --time 0 "hostname-$os-custom"
+    __engine stop --time 0 "$TEST_ID-$os-custom"
 
 done
diff --git a/tests/t/ignition.sh b/tests/t/ignition.sh
index d2cab70..e2cfb9c 100644
--- a/tests/t/ignition.sh
+++ b/tests/t/ignition.sh
@@ -26,14 +26,14 @@ EOF
 
 __engine run \
     --rm --detach \
-    --name ignition \
+    --name "$TEST_ID" \
     "$image" \
     --ignition "$TEMP_DIR/config.ign"
 
 __test() {
-    __engine exec ignition --as "$user" "cmp $home/file <<< hello"
+    __engine exec "$TEST_ID" --as "$user" "cmp $home/file <<< hello"
 }
 
 __test
-__engine restart ignition
+__engine restart "$TEST_ID"
 __test
diff --git a/tests/t/mount.sh b/tests/t/mount.sh
index cebb2e5..726b596 100644
--- a/tests/t/mount.sh
+++ b/tests/t/mount.sh
@@ -1,6 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 
-for os in fedora coreos; do
+for os in "${!TEST_IMAGES[@]}"; do
 
     image="${TEST_IMAGES[$os]}"
     user="${TEST_IMAGES_DEFAULT_USER[$os]}"
@@ -10,37 +10,37 @@ for os in fedora coreos; do
 
     __engine run \
         --rm --detach \
-        --name "mount-$os" \
+        --name "$TEST_ID-$os" \
         --volume "$TEMP_DIR/file:$home/file:z" \
         --volume "$TEMP_DIR:$home/dir:z" \
         --mount "type=tmpfs,dst=$home/tmp" \
         "$image"
 
     __test() {
-        __engine exec "mount-$os" --as "$user"
+        __engine exec "$TEST_ID-$os" --as "$user"
 
-        __engine exec "mount-$os" --as "$user" "
+        __engine exec "$TEST_ID-$os" --as "$user" "
             set -e
             [[ -b $home/file ]]
             sudo cmp -n 6 $home/file <<< hello
             "
 
-        __engine exec "mount-$os" --as "$user" "
+        __engine exec "$TEST_ID-$os" --as "$user" "
             set -e
             mount -l | grep '^virtiofs-0 on $home/dir type virtiofs'
             [[ -d $home/dir ]]
             sudo cmp $home/dir/file <<< hello
             "
 
-        __engine exec "mount-$os" --as "$user" "
+        __engine exec "$TEST_ID-$os" --as "$user" "
             mount -l | grep '^tmpfs on $home/tmp type tmpfs'
             "
     }
 
     __test
-    __engine restart "mount-$os"
+    __engine restart "$TEST_ID-$os"
     __test
 
-    __engine stop --time 0 "mount-$os"
+    __engine stop --time 0 "$TEST_ID-$os"
 
 done
diff --git a/tests/t/persistent.sh b/tests/t/persistent.sh
index 0f8c266..e850031 100644
--- a/tests/t/persistent.sh
+++ b/tests/t/persistent.sh
@@ -9,14 +9,14 @@ fi
 
 # Usage: __run <crun_vm_option> [<extra_podman_options...>]
 __run() {
-    __engine run --rm --detach --name persistent "${@:2}" --rootfs "$TEMP_DIR" "$1"
+    __engine run --rm --detach --name "$TEST_ID" "${@:2}" --rootfs "$TEMP_DIR" "$1"
 }
 
 # Usage: __test <crun_vm_option> <condition>
 __test() {
     id=$( __run "$1" )
-    __engine exec persistent --as fedora "$2"
-    __engine stop persistent
+    __engine exec "$TEST_ID" --as fedora "$2"
+    __engine stop "$TEST_ID"
 
     if [[ "$ENGINE" != rootful-podman ]]; then
         # ensure user that invoked `engine run` can delete crun-vm state
@@ -34,4 +34,4 @@ __test ""           '[[ -e i-was-here ]]'
 ! RUST_LIB_BACKTRACE=0 __run --persistent --read-only
 
 __run "" --read-only
-__engine exec persistent --as fedora
+__engine exec "$TEST_ID" --as fedora
diff --git a/tests/t/publish.sh b/tests/t/publish.sh
index 4c9b98a..07213d9 100644
--- a/tests/t/publish.sh
+++ b/tests/t/publish.sh
@@ -1,30 +1,33 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 
-image="${TEST_IMAGES[fedora]}"
-user="${TEST_IMAGES_DEFAULT_USER[fedora]}"
+trap '__engine stop "$TEST_ID"' EXIT
 
-__engine run \
-    --rm --detach \
-    --name publish \
-    --publish 127.0.0.1::8000 \
-    "$image"
+for os in fedora fedora-bootc; do
 
-endpoint=$( __engine port publish | tee /dev/stderr | cut -d' ' -f3 )
+    image="${TEST_IMAGES[$os]}"
+    user="${TEST_IMAGES_DEFAULT_USER[$os]}"
 
-__engine exec publish --as "$user"
+    __engine run --rm --detach --name "$TEST_ID" --publish 127.0.0.1::8000 "$image"
 
-__log 'Ensuring curl fails...'
-! curl "$endpoint" 2>/dev/null
+    endpoint=$( __engine port "$TEST_ID" | tee /dev/stderr | cut -d' ' -f3 )
 
-__engine exec publish --as "$user" python -m http.server &
-trap '__engine stop publish' EXIT
+    __engine exec "$TEST_ID" --as "$user"
 
-__log 'Ensuring curl succeeds...'
+    __log 'Ensuring curl fails...'
+    ! curl "$endpoint" 2>/dev/null
 
-i=0
-max_tries=30
+    __engine exec "$TEST_ID" --as "$user" python -m http.server &
+
+    __log 'Ensuring curl succeeds...'
+
+    i=0
+    max_tries=30
+
+    until [[ "$( curl "$endpoint" 2>/dev/null )" == '<!DOCTYPE HTML>'* ]]; do
+        (( ++i < max_tries ))
+        sleep 1
+    done
+
+    __engine stop "$TEST_ID"
 
-until [[ "$( curl "$endpoint" 2>/dev/null )" == '<!DOCTYPE HTML>'* ]]; do
-    (( ++i < max_tries ))
-    sleep 1
 done
diff --git a/tests/t/random-ssh-key-pair.sh b/tests/t/random-ssh-key-pair.sh
index fcd7afd..3fe4c1d 100644
--- a/tests/t/random-ssh-key-pair.sh
+++ b/tests/t/random-ssh-key-pair.sh
@@ -2,10 +2,10 @@
 
 __engine run \
     --detach \
-    --name random-ssh-key-pair \
+    --name "$TEST_ID" \
     "${TEST_IMAGES[fedora]}" \
     --random-ssh-key-pair
 
-__engine exec random-ssh-key-pair --as fedora
-__engine restart random-ssh-key-pair
-__engine exec random-ssh-key-pair --as fedora
+__engine exec "$TEST_ID" --as fedora
+__engine restart "$TEST_ID"
+__engine exec "$TEST_ID" --as fedora
diff --git a/tests/t/stop-start.sh b/tests/t/stop-start.sh
index bdce535..d0e47fb 100644
--- a/tests/t/stop-start.sh
+++ b/tests/t/stop-start.sh
@@ -1,14 +1,14 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 
-__engine run --detach --name stop-start "${TEST_IMAGES[fedora]}"
+__engine run --detach --name "$TEST_ID" "${TEST_IMAGES[fedora]}"
 
-__engine exec stop-start --as fedora '[[ ! -e i-was-here ]] && touch i-was-here'
+__engine exec "$TEST_ID" --as fedora '[[ ! -e i-was-here ]] && touch i-was-here'
 
 for (( i = 0; i < 2; ++i )); do
 
-    __engine stop stop-start
-    __engine start stop-start
+    __engine stop "$TEST_ID"
+    __engine start "$TEST_ID"
 
-    __engine exec stop-start --as fedora '[[ -e i-was-here ]]'
+    __engine exec "$TEST_ID" --as fedora '[[ -e i-was-here ]]'
 
 done