Building docker-vm-runner to Start Real Virtual Machines with Docker-Like Commands

20 min read

Introduction

Starting a Linux environment with Docker is easy.

docker run --rm -it ubuntu

This is all you need to try out Ubuntu’s userland right away.

However, there are situations where containers are not enough.

For example, you may want to run systemd normally, check kernel or boot behavior, start an OS installer, or verify disk layout, firmware, cloud-init, network boot, and so on.

For this kind of use, you still need a VM instead of a container.

On the other hand, dealing with QEMU and libvirt directly is somewhat troublesome. Writing long qemu-system-x86_64 options every time is tedious, and so is assembling disks, networks, port forwarding, VNC, SSH, cloud-init, and related settings each time.

Therefore, I created docker-vm-runner, a tool that starts a real VM with a Docker-like workflow.

Here is the repository.

https://github.com/MuNeNiCK/docker-vm-runner

The docs are here.

https://munenick.github.io/docker-vm-runner/

This article provides an overview and typical usage. Detailed environment variables, networks, storage, access methods, agent skills, and examples are covered in the docs.

What is docker-vm-runner?

docker-vm-runner is a tool for starting a VM with QEMU/libvirt from inside a Docker container.

As the name suggests, it runs a VM with Docker.

However, what it starts is not a container. The guest OS is an actual virtual machine with its own kernel, firmware, disks, and console.

Therefore, it can be used for the following purposes that are difficult to verify with regular containers.

- Confirming that cloud images boot
- Booting OS installer ISOs
- Verifying systemd and kernel modules
- Validating cloud-init
- Verifying disk size and additional disks
- Network verification such as NAT, bridge, and direct modes
- Network boot verification using iPXE
- GUI installer operation using VNC/noVNC
- BMC-style control using Redfish/IPMI
- In-guest command execution via QEMU Guest Agent

If a container is sufficient, I think a container is fine. However, if you want to handle the behavior of the OS itself or parts that cannot be reproduced without a VM, it is more straightforward to use a VM.

docker-vm-runner brings VM startup and access closer to the Docker workflow.

Try starting it first

The simplest way to start it is as follows.

docker run --rm -it \
  --name docker-vm-runner \
  --device /dev/kvm \
  ghcr.io/munenick/docker-vm-runner:latest

If /dev/kvm is available on your Linux host, you can start the VM with hardware assistance from KVM.

In an environment without /dev/kvm, you can fall back to software emulation by removing --device /dev/kvm as shown below.

docker run --rm -it \
  --name docker-vm-runner \
  ghcr.io/munenick/docker-vm-runner:latest

For performance, however, it is best to run it on a Linux host that supports KVM.

When started, it connects to the VM’s serial console.

This form is sufficient if you only want to try it temporarily. Stopping the container also deletes the VM disk and downloaded image cache.

Make the disk persistent

Mount /data if you want to keep the disk and image cache instead of recreating the VM every time.

docker run --rm -it \
  --name docker-vm-runner \
  --device /dev/kvm \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

Mounting /data preserves the VM’s disk, state, and image cache.

This format is convenient for small verification VMs.

If you want to start it in the background, use -dit.

docker run -dit --name docker-vm-runner \
  --device /dev/kvm \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

To view the logs, use the following.

docker logs -f docker-vm-runner

To reconnect to the serial console, follow the steps below.

docker attach docker-vm-runner

If you want to exit the VM without stopping the Docker attach session, press Ctrl+P and then Ctrl+Q.

Select the OS to boot

docker-vm-runner can use image IDs from os-iso-catalog to select the OS image to boot.

The default catalog is below.

https://munenick.github.io/os-iso-catalog/v1/all.json

A list of available images can be found below.

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros

To search for Ubuntu, use the following.

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros --search ubuntu

You can also filter by image type.

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros --type cloud-image

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros --type iso

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros --type disk-image

For everyday use, cloud-image is convenient because it uses cloud-init and starts quickly.

If you want to boot a specific image, specify DISTRO.

docker run --rm -it \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

The default guest user is user and the password is password.

If you want to include an SSH key, specify SSH_PUBKEY.

docker run --rm -it \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

Check settings before startup

When adding complex settings, it is useful to run --show-config and --dry-run before starting the VM.

To see the resolved settings, use the following.

docker run --rm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e CPUS=4 \
  -e MEMORY=8192 \
  -e NETWORK_MODE=nat \
  -e PORT_FWD=8080:80 \
  ghcr.io/munenick/docker-vm-runner:latest --show-config

If you only want to verify the configuration without starting the VM, use the following.

docker run --rm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e CPUS=4 \
  -e MEMORY=8192 \
  ghcr.io/munenick/docker-vm-runner:latest --dry-run

If you want to see the VM’s XML, you can also use --show-xml.

These options are useful when the number of environment variables grows.

Connect with SSH

The default NAT network forwards port 2222 on the host side to port 22 on the guest.

docker run --rm -it \
  --device /dev/kvm \
  -p 2222:2222 \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

After startup, you can connect as follows.

ssh user@localhost -p 2222

If you want to change the password, specify GUEST_PASSWORD.

docker run --rm -it \
  --device /dev/kvm \
  -p 2222:2222 \
  -e GUEST_PASSWORD='change-me' \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

If you want to forward additional ports, use PORT_FWD.

For example, if you want to expose port 80 on the guest as port 8080 on the host side, use the following.

docker run --rm -it \
  --device /dev/kvm \
  -p 8080:8080 \
  -e PORT_FWD=8080:80 \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

PORT_FWD is of the form container_port:guest_port. If you specify more than one, separate them with commas.

-e PORT_FWD=8080:80,8443:443

Docker’s -p must also be specified.

Run a command inside the guest

docker-vm-runner also provides guest-exec which uses the QEMU Guest Agent to execute commands inside the guest.

First, start the VM in the background.

docker run -dit --name ubuntu-vm \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -v ubuntu-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

You can then run in-guest commands from the host side as follows:

docker exec ubuntu-vm guest-exec --wait "uname -a"

It can also be executed in argument form.

docker exec ubuntu-vm guest-exec --wait id user

--wait will wait until the VM domain and QEMU Guest Agent are available before running the command.

This is useful for CI and automated testing, as it allows you to run OS-level commands inside a VM without logging in via SSH.

For example, you can use it when you want to run systemctl or a package manager inside a VM instead of a container.

docker exec ubuntu-vm guest-exec --wait systemctl is-system-running
docker exec ubuntu-vm guest-exec --wait "apt-get update"

Note that guest-exec runs through the QEMU Guest Agent, not the PTY. Therefore, depending on the command, standard output buffering and stdout/stderr order may be slightly different from normal terminal execution.

ISO installation is also possible

It supports installation from ISOs as well as cloud images.

docker run --rm -it \
  --device /dev/kvm \
  -e BOOT_FROM=https://example.com/installer.iso \
  -e GUEST_NAME=installed-vm \
  -e CPUS=4 \
  -e MEMORY=8192 \
  -e DISK_SIZE=80G \
  -v installed-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

If you want to boot from the ISO, prepare a blank disk and boot from the CD-ROM first.

The next time the VM is booted after the installation is complete, it will boot from the installed disk instead of the ISO.

If you want to connect the ISO again or start it repeatedly as a rescue ISO, use FORCE_ISO=1.

docker run --rm -it \
  --device /dev/kvm \
  -e BOOT_FROM=https://example.com/rescue.iso \
  -e FORCE_ISO=1 \
  -e GUEST_NAME=rescue-vm \
  -v rescue-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

BOOT_FROM can be a URL, local path, OCI reference, ISO, disk image, blank, etc.

If you want to start with a blank disk, do the following:

docker run --rm -it \
  --device /dev/kvm \
  -e BOOT_FROM=blank \
  -e DISK_SIZE=40G \
  -v blank-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

Operate from a browser with noVNC

If you want to use the GUI installer, you can enable noVNC.

docker run --rm -it \
  --device /dev/kvm \
  -p 6080:6080 \
  -e GRAPHICS=novnc \
  -e BOOT_FROM=https://example.com/desktop-installer.iso \
  -e GUEST_NAME=desktop-install \
  -e MEMORY=8192 \
  -e DISK_SIZE=80G \
  -v desktop-install-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

After starting, open the following in your browser.

https://localhost:6080/

noVNC endpoints use self-signed certificates.

If you want to prepare a separate VNC client, you can also use GRAPHICS=vnc.

docker run --rm -it \
  --device /dev/kvm \
  -p 5900:5900 \
  -e GRAPHICS=vnc \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

In this case, connect to localhost:5900 with a VNC client.

Network

By default, NAT is used.

This is similar to starting a container with Docker and exposing only the necessary ports with -p. For normal verification, NAT is the easiest place to start.

On the other hand, if you want your VM to appear as a regular machine on your LAN, you can use bridge mode.

docker run -d --name bridge-vm \
  --network host \
  --cap-add NET_ADMIN \
  --device /dev/kvm \
  --device /dev/net/tun \
  --device /dev/vhost-net \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e NETWORK_MODE=bridge \
  -e NETWORK_BRIDGE=br0 \
  -e NO_CONSOLE=1 \
  -v bridge-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

When using bridge mode, a Linux bridge such as br0 must exist on the host.

If you want to attach a VM directly to a physical NIC, you can also use direct mode.

MACVTAP_MAJOR="$(awk '$2 == "macvtap" { print $1 }' /proc/devices)"

docker run -d --name direct-vm \
  --network host \
  --cap-add NET_ADMIN \
  --device /dev/kvm \
  --device /dev/vhost-net \
  --device-cgroup-rule "c ${MACVTAP_MAJOR}:* rwm" \
  -v /dev:/dev:ro \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e NETWORK_MODE=direct \
  -e NETWORK_DIRECT_DEV=eth0 \
  -e NO_CONSOLE=1 \
  -v direct-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

Bridge and direct modes strongly depend on the host network configuration. It is best to start with NAT and use bridge or direct mode only when necessary.

It also supports multiple NICs. For example, you can use the first NIC as a NAT management NIC and the second as a bridge or direct verification NIC.

docker run --rm \
  -e DISTRO=alpine-3.22-cloud-amd64 \
  -e NETWORK_MODE=nat \
  -e NETWORK2_MODE=bridge \
  -e NETWORK2_BRIDGE=br0 \
  ghcr.io/munenick/docker-vm-runner:latest --show-config

Network boot with iPXE

docker-vm-runner also supports network booting via iPXE.

docker run --rm -it \
  --network host \
  --cap-add NET_ADMIN \
  --device /dev/kvm \
  --device /dev/net/tun \
  --device /dev/vhost-net \
  -e IPXE_ENABLE=1 \
  -e BOOT_ORDER=network,hd \
  -e NETWORK_MODE=bridge \
  -e NETWORK_BRIDGE=br0 \
  -e GUEST_NAME=ipxe-vm \
  -v ipxe-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

IPXE_ENABLE=1 injects the iPXE ROM into the primary NIC and prioritizes network boot in the boot order.

This is useful when you want to verify a PXE-based installer environment or a mechanism such as netboot.xyz.

In a real PXE environment, guests need to be able to reach upstream services like DHCP/TFTP/HTTP. Therefore, bridge and direct modes are more realistic than NAT.

Storage

The main disk size can be specified with DISK_SIZE.

docker run --rm -it \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e DISK_SIZE=40G \
  -v storage-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

DISK_SIZE can also be specified as half or max.

Additional disks can be specified with DISK2_SIZE to DISK6_SIZE.

docker run --rm -it \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e DISK_TYPE=scsi \
  -e DISK2_SIZE=100G \
  -v storage-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

The disk controller can be specified with DISK_TYPE. Supported values are virtio, scsi, nvme, ide, and usb.

You can also share a host directory with guests.

mkdir -p share

docker run --rm -it \
  --device /dev/kvm \
  -v "$PWD/share:/shared" \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e FILESYSTEM_SOURCE=/shared \
  -e FILESYSTEM_TARGET=shared \
  -e FILESYSTEM_DRIVER=9p \
  -e FILESYSTEM_ACCESSMODE=mapped \
  -v shared-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

On the guest side, you can mount it as follows.

sudo mkdir -p /mnt/shared
sudo mount -t 9p -o trans=virtio,version=9p2000.L shared /mnt/shared

virtiofs can also be used, but access mode settings and related options differ from 9p. For detailed settings, see Storage and Reference in the docs.

Control a VM with Redfish/IPMI

docker-vm-runner can also enable Redfish and IPMI.

This is an example of enabling Redfish.

docker run --rm -it \
  --device /dev/kvm \
  -p 8443:8443 \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e REDFISH_ENABLE=1 \
  -e REDFISH_PASSWORD='change-me' \
  -v redfish-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

Check it with the following.

curl -k -u admin:change-me https://localhost:8443/redfish/v1/

If you enable Redfish, change REDFISH_PASSWORD instead of leaving the default password.

IPMI can be enabled as well.

docker run --rm -it \
  --device /dev/kvm \
  -p 623:623/udp \
  -e IPMI_ENABLE=1 \
  -e IPMI_PASSWORD='change-me' \
  -v ipmi-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest
ipmitool -I lanplus -U admin -P change-me -H localhost -p 623 power status

IPMI controls the libvirt VM via VirtualBMC.

This is a somewhat unusual feature for a local VM runner. It is useful when you want to test physical server control tools or bare metal provisioning tools against VMs.

Examples of Ironic integration

Examples are also available in the docs.

https://munenick.github.io/docker-vm-runner/examples/

For example, there is an example that starts multiple VMs with Docker Compose.

examples/multiple-vms/

This is an example of starting Ubuntu and Alpine VMs as one Compose project and making each accessible via SSH and noVNC.

There are also examples that operate VMs from Ironic using Redfish and IPMI.

examples/redfish-ironic/
examples/ipmi-ironic/

These do not recreate a complete OpenStack environment, but you can see locally how Ironic discovers nodes via Redfish/IPMI, validates them, and manipulates boot devices.

There are also examples using iPXE and netboot.xyz.

examples/ipxe-netbootxyz/

Commands that are difficult to explain as one-liners are summarized in the examples as Docker Compose files.

Skills for AI agents

Recently, commands are increasingly being executed by AI coding agents such as Codex, Claude Code, and OpenCode.

However, you may want to avoid having an agent interact directly with the host environment.

On the other hand, regular containers do not allow verification of systemd, kernels, OS installers, network boot, and similar areas.

Therefore, docker-vm-runner also provides skills for AI coding agents.

https://munenick.github.io/docker-vm-runner/agent-skills/

Using this skill, you can easily have an agent perform the following VM operations.

- Starting a temporary or persistent VM
- In-guest command execution with guest-exec
- SSH connection
- GUI installer operation with noVNC/VNC
- BMC power control with Redfish
- Booting from ISO, cloud image, blank disk, iPXE, etc.
- Configuring networks, additional disks, file shares, and block devices

If you can use the GitHub CLI, you can install it as follows.

For Codex:

gh skill install MuNeNICK/docker-vm-runner skills/docker-vm-runner-agent \
  --agent codex \
  --scope user

For Claude Code:

gh skill install MuNeNICK/docker-vm-runner skills/docker-vm-runner-agent \
  --agent claude-code \
  --scope user

For OpenCode:

gh skill install MuNeNICK/docker-vm-runner skills/docker-vm-runner-agent \
  --agent opencode \
  --scope user

This is useful when you want an agent to verify OS-level behavior without directly touching the host environment.

Cleanup

If you start a VM many times, runtime resources may remain from the previous container termination.

In that case, you can use --cleanup.

docker run --rm \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest --cleanup

If /data is mounted, this removes remaining runtime resources while leaving the persistent disk and cache intact.

If you specified GUEST_NAME or DISTRO, specify the same value during cleanup.

When to use it

The main uses I envision are as follows.

Verifying the OS image

It can be used to check whether cloud images and disk images start correctly, whether cloud-init succeeds, and whether SSH is available.

Testing Kubernetes and infrastructure scripts

Kubernetes setup scripts and similar infrastructure scripts often depend on systemd, swap, kernel modules, iptables, firewalld, SELinux/AppArmor, and other OS-level features that are difficult to verify in containers.

It is safer to test these on a VM.

By using docker-vm-runner, you can test them on a real VM while staying close to the Docker workflow.

Verifying the OS installer

You can boot the ISO, install it to the disk, and then boot from the installed disk next time.

If you need a GUI installer, you can also use noVNC.

Verifying bare metal tools

It supports Redfish/IPMI, so you can try tools designed for BMCs against a VM.

This is useful if you want to check a tool like Ironic to some extent without a physical server.

Isolated execution environment for agents

If you want an AI coding agent to validate something, a container may not be enough, but you may also not want it to touch the host directly.

In that case, you can use the VM as a sandbox.

Notes

docker-vm-runner is primarily targeted at Linux hosts.

Some features may work on macOS and Windows depending on the environment, such as Docker Desktop or WSL2, but KVM and host networking are not as easy to use as they are on Linux.

Bridge, direct mode, host block devices, GPU, USB, TPM, file system shares, and similar features depend on host-side devices and Docker runtime options.

Conclusion

docker-vm-runner is a tool that allows you to start a real VM as easily as Docker.

Anything that can be done with a container should usually stay in a container.

However, containers have their limitations when it comes to areas like systemd, kernel, firmware, ISO, disks, networking, and Redfish/IPMI.

The purpose of this tool is to let you handle VMs in your Docker workflow when you need to go beyond that boundary, instead of writing QEMU/libvirt commands directly.

Please try it if you are interested.