Introduction
We started a real Firecracker microVM by hand on 12 October 2026. The microVM booted, got an IP address, and reached the internet over HTTPS.
What you will learn
- How to check that a server can run KVM.
- How to get a tested guest kernel and a guest disk image from the Firecracker CI bucket.
- How to build an ext4 root file system with an SSH key in it.
- How to start Firecracker and configure a microVM with its REST API.
- How to give the guest a network, a default route, and DNS.
- How to find and fix the problems that we found.
Test setup
We ran the test on an AWS cloud server. The server is a virtual machine itself, so our microVM ran with nested KVM.
| Item | Value |
|---|---|
| Date of the run | 12 October 2026 |
| Server | AWS cloud server, host name microvm-test |
| Operating system | Ubuntu 24.04.5 LTS |
| Host kernel | 6.8.0-142-generic |
| CPU | x86_64, AMD (kvm_amd) |
| Virtualization | Nested KVM (systemd-detect-virt prints kvm) |
| Work folder | ~/fc_lab |
| Firecracker | v1.17.0 |
| Guest kernel | vmlinux-6.18.51 |
| Guest disk | ubuntu-24.04.ext4 (1 GB) |
We used two terminals on the host:
- Terminal 1 runs Firecracker. After the boot, it shows the guest console.
- Terminal 2 sends the commands and the API requests.
Step 1: Check the server
First, make sure that the host can run KVM. Firecracker needs read and write access to /dev/kvm.
We ran these commands on the host. The output includes two typos: ls -l /dev|kvm and systemd-detect=virt.
Command (host, typo):
ls -l /dev|kvm
Output:
Command 'kvm' not found, but can be installed with:
apt install qemu-system-x86
Command (host):
ls -l /dev/kvm
Output:
crw-rw---- 1 root kvm 10, 232 Oct 12 09:55 /dev/kvm
Commands (host):
uname -m
uname -r
Output:
x86_64
6.8.0-142-generic
Command (host):
cat /etc/os-release
Output:
PRETTY_NAME="Ubuntu 24.04.5 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.5 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
Command (host):
lsmod | grep kvm
Output:
kvm_amd 212992 0
ccp 147456 1 kvm_amd
kvm 1404928 1 kvm_amd
irqbypass 12288 1 kvm
Command (host):
[ -r /dev/kvm ] && [ -w /dev/kvm ] && echo "OK" || echo "NO ACCESS"
Output:
OK
Command (host, typo):
systemd-detect=virt
Output:
Command 'systemd-detect=virt' not found, did you mean:
command 'systemd-detect-virt' from deb systemd (255.4-1ubuntu8.16)
Try: apt install <deb name>
Command (host):
systemd-detect-virt
Output:
kvm
Command (host):
grep -c -E 'vmx|svm' /proc/cpuinfo
Output:
2
Command (host, typo):
which curl jq ip iptables unsquashfs mkfs.ext4 docjer
Output:
/usr/bin/curl
/usr/bin/jq
/usr/sbin/ip
/usr/sbin/iptables
/usr/bin/unsquashfs
/usr/sbin/mkfs.ext4
Command (host):
which curl jq ip iptables unsquashfs mkfs.ext4 docker
Output:
/usr/bin/curl
/usr/bin/jq
/usr/sbin/ip
/usr/sbin/iptables
/usr/bin/unsquashfs
/usr/sbin/mkfs.ext4
What the output shows:
| Output | Meaning |
|---|---|
crw-rw---- 1 root kvm 10, 232 ... /dev/kvm |
The KVM device exists. It belongs to the kvm group. |
kvm_amd in lsmod |
The CPU is AMD, and the KVM module is loaded. |
OK |
The root user can read and write /dev/kvm. |
systemd-detect-virt prints kvm |
The server is a VM itself. Thus, the microVM uses nested KVM. |
grep -c prints 2 |
The virtualization flag shows on 2 vCPUs. |
which finds all tools except docker |
Docker was not installed at this time. |
The two typos did no harm. In /dev|kvm, we typed a pipe (|) in place of a slash. In systemd-detect=virt, we typed an equals sign in place of a hyphen. The shell showed an error, and we typed the command again.
The second which command also did not find docker. The first command had a typo (docjer), but the result is the same.
Step 2: Check Docker and KVM access again
After the first check, we installed Docker. Then we checked Docker and KVM access again:
Command (host):
docker ps
Output:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Command (host):
ls -l /dev/kvm && [ -r /dev/kvm ] && [ -w /dev/kvm ] && echo "OK"
Output:
crw-rw---- 1 root kvm 10, 232 Oct 12 09:55 /dev/kvm
OK
Docker runs, and KVM access still works. The official guide does not need Docker for the steps in this post.
Step 3: Get the guest kernel
The Firecracker team publishes tested guest kernels and disk images in an Amazon S3 bucket. We download the newest kernel from this bucket.
3.1 Make the work folder
Commands (host):
mkdir -p ~/fc_lab && cd ~/fc_lab
which wget e2fsck ssh-keygen
Output not recorded. Our later prompts show the folder ~/fc_lab.
3.2 Find the newest build folder
Set two variables. Then ask the bucket for its list of build folders, and keep the newest one.
Command (host):
pwd
Output:
/root/fc_lab
Commands (host):
ARCH="$(uname -m)"
S3="https://s3.amazonaws.com/spec.ccfc.min"
echo "$ARCH $S3"
Output:
x86_64 https://s3.amazonaws.com/spec.ccfc.min
Commands (host):
CI_ARTIFACTS_PREFIX=$(curl -fsSL "$S3?list-type=2&prefix=firecracker-ci/&delimiter=/" \
| grep -oP "(?<=<Prefix>)firecracker-ci/[0-9]{8}-[^/]+/(?=</Prefix>)" \
| sort \
| tail -1)
echo "$CI_ARTIFACTS_PREFIX"
Output:
firecracker-ci/20261008-40f3a4819490-0/
The newest build folder has the date 8 October 2026.
3.3 Find the kernel file name
Commands (host):
latest_kernel_key=$(curl -fsSL "$S3?list-type=2&prefix=${CI_ARTIFACTS_PREFIX}${ARCH}/vmlinux-" \
| grep -oP "(?<=<Key>)(${CI_ARTIFACTS_PREFIX}${ARCH}/vmlinux-[0-9]+\.[0-9]+\.[0-9]{1,3})(?=</Key>)" \
| sort -V \
| tail -1)
echo "$latest_kernel_key"
Output:
firecracker-ci/20261008-40f3a4819490-0/x86_64/vmlinux-6.18.51
The newest kernel is vmlinux-6.18.51.
3.4 Download the kernel and check it
Command (host):
wget "$S3/${latest_kernel_key}"
The output of the download was not recorded. These are the checks after the download:
Command (host):
ls -lh vmlinux-*
Output:
-rw-r--r-- 1 root root 27M Oct 8 15:47 vmlinux-6.18.51
Command (host):
file vmlinux-*
Output:
vmlinux-6.18.51: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=e83be02d4f83081c059426b339e8ba4c48f175a3, not stripped
The kernel file is 27 MB. The file command shows an ELF executable. This is an uncompressed kernel, and Firecracker needs this format.
Step 4: Get the guest disk image
The guest needs a root file system. The bucket has an Ubuntu 24.04 image in squashfs format.
4.1 First attempt, and why it failed
We tried to copy the SSH public key into the image folder too early. At that time, the folder had only the two key files:
Command (host):
cp -v id_rsa.pub squashfs-root/root/.ssh/authorized_keys
Output:
'id_rsa.pub' -> 'squashfs-root/root/.ssh/authorized_keys'
cp: cannot create regular file 'squashfs-root/root/.ssh/authorized_keys': No such file or directory
Commands (host):
pwd
ls
Output:
/root/fc_lab
id_rsa id_rsa.pub
The folder squashfs-root did not exist yet. The unsquashfs command makes this folder. Thus, we went back, downloaded the image, and unpacked it first.
Lesson: Do the steps in the order of the guide. Unpack the image before you copy files into it.
4.2 Find the image file name
Output of echo "$latest_ubuntu_key" (the command was above the cut):
firecracker-ci/20261008-40f3a4819490-0/x86_64/ubuntu-24.04.squashfs
Commands (host):
ubuntu_version=$(basename $latest_ubuntu_key .squashfs | grep -oE '[0-9]+\.[0-9]+')
echo "$ubuntu_version"
Output:
24.04
The first line is the result of echo "$latest_ubuntu_key". The top of the terminal paste was cut, so the command that set this variable is not shown.
4.3 Download the image and check it
Command (host):
wget -O ubuntu-$ubuntu_version.squashfs.upstream "$S3/$latest_ubuntu_key"
Output:
--2026-10-12 10:29:07-- https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/20261008-40f3a4819490-0/x86_64/ubuntu-24.04.squashfs
Resolving s3.amazonaws.com (s3.amazonaws.com)... 16.15.244.191, 52.217.117.240, 16.182.65.192, ...
Connecting to s3.amazonaws.com (s3.amazonaws.com)|16.15.244.191|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 108199936 (103M) [binary/octet-stream]
Saving to: ‘ubuntu-24.04.squashfs.upstream’
ubuntu-24.04.squashfs.upstream 100%[======================>] 103.19M 65.8MB/s in 1.6s
2026-10-12 10:29:09 (65.8 MB/s) - ‘ubuntu-24.04.squashfs.upstream’ saved [108199936/108199936]
Command (host):
ls -lh ubuntu-*.squashfs.upstream
Output:
-rw-r--r-- 1 root root 104M Oct 8 15:47 ubuntu-24.04.squashfs.upstream
Command (host):
file ubuntu-*.squashfs.upstream
Output:
ubuntu-24.04.squashfs.upstream: Squashfs filesystem, little endian, version 4.0, zstd compressed, 108197969 bytes, 9188 inodes, blocksize: 131072 bytes, created: Thu Oct 8 15:01:07 2026
The image is 108,199,936 bytes (103 MB). The download took 1.6 seconds at 65.8 MB/s. We shortened the progress bar line in this post to fit the page. The numbers on it are unchanged.
The file command shows a squashfs file system that is compressed with zstd.
4.4 Unpack the image
Command (host):
unsquashfs ubuntu-$ubuntu_version.squashfs.upstream
The recorded output starts at the summary lines:
Output (summary lines):
created 7055 files
created 1466 directories
created 667 symlinks
created 0 devices
created 0 fifos
created 0 sockets
created 2 hardlinks
Command (host):
ls squashfs-root
Output:
bin dev etc home lib lib64 proc root run sbin sys tmp usr var
Command (host):
ls -a squashfs-root/root
Output:
. .. .bashrc .profile .ssh
The command made 7,055 files, 1,466 directories, and 667 symbolic links. The folder squashfs-root/root/.ssh exists now, so the copy in the next step can work.
Step 5: Add the SSH key and build the ext4 disk
Firecracker needs a block device file for the root disk. We put our SSH public key in the guest, and then we build an ext4 file from the unpacked folder.
5.1 Put the public key in the guest
Copy the public key into the guest as authorized_keys. Keep the private key on the host, and give it a name that matches the image.
Command (host):
cp -v id_rsa.pub squashfs-root/root/.ssh/authorized_keys
Output:
'id_rsa.pub' -> 'squashfs-root/root/.ssh/authorized_keys'
Command (host):
mv -v id_rsa ./ubuntu-$ubuntu_version.id_rsa
Output:
renamed 'id_rsa' -> './ubuntu-24.04.id_rsa'
Command (host):
cat squashfs-root/root/.ssh/authorized_keys
Output:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEYi+x3lBmevdAJtkKgecGoRYWhO5l5qPMCzJxRjswof root@microvm-test
Command (host):
ls -l *.id_rsa id_rsa.pub
Output:
-rw-r--r-- 1 root root 99 Oct 12 10:23 id_rsa.pub
-rw------- 1 root root 411 Oct 12 10:23 ubuntu-24.04.id_rsa
The key type is ed25519, but the file name is id_rsa. The name is only a label.
The private key has 411 bytes at this point. Remember this number. It is important in Step 18.
5.2 Build the ext4 disk
Make an empty file of 1 GB. Then fill it with the files from squashfs-root.
Commands (host):
sudo chown -R root:root squashfs-root
truncate -s 1G ubuntu-$ubuntu_version.ext4
Output: none.
Command (host):
ls -lh ubuntu-$ubuntu_version.ext4
Output:
-rw-r--r-- 1 root root 1.0G Oct 12 10:31 ubuntu-24.04.ext4
Command (host):
sudo mkfs.ext4 -d squashfs-root -F ubuntu-$ubuntu_version.ext4
Output:
mke2fs 1.47.0 (5-Feb-2023)
Discarding device blocks: done
Creating filesystem with 262144 4k blocks and 65536 inodes
Filesystem UUID: 9a6ab1fa-31b2-4ce0-b2bf-3417de5a3067
Superblock backups stored on blocks:
32768, 98304, 163840, 229376
Allocating group tables: done
Writing inode tables: done
Creating journal (8192 blocks): done
Copying files into the device: done
Writing superblocks and filesystem accounting information: done
The disk has 262,144 blocks of 4 KB, which is 1 GB.
Step 6: Check the three files
Before you start the VM, make sure that the kernel, the root file system, and the SSH key are correct. This script checks all three:
Commands (host):
KERNEL=$(ls vmlinux-* | tail -1)
[ -f $KERNEL ] && echo "Kernel: $KERNEL" || echo "ERROR: Kernel $KERNEL does not exist"
ROOTFS=$(ls *.ext4 | tail -1)
e2fsck -fn $ROOTFS &>/dev/null && echo "Rootfs: $ROOTFS" || echo "ERROR: $ROOTFS is not a valid ext4 fs"
KEY_NAME=$(ls *.id_rsa | tail -1)
[ -f $KEY_NAME ] && echo "SSH Key: $KEY_NAME" || echo "ERROR: Key $KEY_NAME does not exist"
Output:
Kernel: vmlinux-6.18.51
Rootfs: ubuntu-24.04.ext4
SSH Key: ubuntu-24.04.id_rsa
All three checks passed.
Note: This check makes sure that the key file exists. It does not check that the file has content. In Step 18, this limit became important.
Step 7: Get the Firecracker program
Ask GitHub for the latest release. Then download the archive and unpack it.
Commands (host):
ARCH="$(uname -m)"
release_url="https://github.com/firecracker-microvm/firecracker/releases"
latest=$(basename $(curl -fsSLI -o /dev/null -w %{url_effective} ${release_url}/latest))
echo "$latest"
Output:
v1.17.0
Command (host):
curl -L ${release_url}/download/${latest}/firecracker-${latest}-${ARCH}.tgz | tar -xz
Output:
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
100 7289k 100 7289k 0 0 17.6M 0 --:--:-- --:--:-- --:--:-- 17.6M
Command (host):
mv release-${latest}-$(uname -m)/firecracker-${latest}-${ARCH} firecracker
Output: none.
Command (host):
ls -lh firecracker
Output:
-rwxr-xr-x 1 14133424 users 3.6M Sep 4 10:45 firecracker
Command (host):
./firecracker --version
Output:
Firecracker v1.17.0
2026-10-12T10:33:11.345509598 [anonymous-instance:main] Firecracker exiting successfully. exit_code=0
The latest release is v1.17.0. The archive is 7,289 KB, and it downloaded at 17.6 MB/s.
The complete VMM is one file of 3.6 MB. The owner shows as a number (14133424), because tar keeps the owner ID from the archive.
Step 8: Start Firecracker (terminal 1)
In terminal 1, remove an old API socket if one exists. Then start Firecracker with a new API socket.
Commands (terminal 1, host):
cd ~/fc_lab
API_SOCKET="/tmp/firecracker.socket"
sudo rm -f $API_SOCKET
sudo ./firecracker --api-sock "${API_SOCKET}" --enable-pci
The recorded output starts at the last command:
Output of the last command:
2026-10-12T10:34:49.770398268 [anonymous-instance:main] Running Firecracker v1.17.0
2026-10-12T10:34:49.770643675 [anonymous-instance:main] Listening on API socket ("/tmp/firecracker.socket").
2026-10-12T10:34:49.771031160 [anonymous-instance:fc_api] API server started.
After these three lines, the terminal waits. This is correct. There is no VM yet, and Firecracker waits for API requests.
We also checked the socket in terminal 2 with ls -l /tmp/firecracker.socket and ss -a | grep /tmp/firecracker.socket. The output of these checks was not recorded.
Step 9: Make the TAP device (terminal 2)
The guest gets its network through a TAP device on the host. A TAP device is like a virtual network cable.
Commands (terminal 2, host):
TAP_IP="172.16.0.1"
MASK_SHORT="/30"
sudo ip link del "$TAP_DEV" 2> /dev/null || true
sudo ip tuntap add dev "$TAP_DEV" mode tap
sudo ip addr add "${TAP_IP}${MASK_SHORT}" dev "$TAP_DEV"
sudo ip link set dev "$TAP_DEV" up
Output: none.
Command (host):
ip addr show tap0
Output:
5: tap0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc fq_codel state DOWN group default qlen 1000
link/ether 3e:7c:38:b7:75:1f brd ff:ff:ff:ff:ff:ff
inet 172.16.0.1/30 scope global tap0
valid_lft forever preferred_lft forever
The recorded output starts at TAP_IP. The line TAP_DEV="tap0" was above the cut. The device tap0 exists, so the variable was set.
The device tap0 has the address 172.16.0.1/30. The state is DOWN with NO-CARRIER. This is normal, because no VM is connected to the device yet.
Step 10: Turn on forwarding and NAT (terminal 2)
The host must pass packets between tap0 and its own network card. NAT (masquerade) then gives the guest traffic the address of the host.
Commands (host):
sudo sh -c "echo 1 > /proc/sys/net/ipv4/ip_forward"
sudo iptables -P FORWARD ACCEPT
HOST_IFACE=$(ip -j route list default |jq -r '.[0].dev')
echo "$HOST_IFACE"
Output:
eth0
Command (host):
sudo iptables -t nat -D POSTROUTING -o "$HOST_IFACE" -j MASQUERADE || true
Output:
iptables: Bad rule (does a matching rule exist in that chain?).
Command (host):
sudo iptables -t nat -A POSTROUTING -o "$HOST_IFACE" -j MASQUERADE
Output: none.
Command (host):
cat /proc/sys/net/ipv4/ip_forward
Output:
1
Command (host):
sudo iptables -t nat -L POSTROUTING -n -v
Output:
Chain POSTROUTING (policy ACCEPT 93 packets, 6791 bytes)
pkts bytes target prot opt in out source destination
0 0 MASQUERADE 0 -- * !docker0 172.17.0.0/16 0.0.0.0/0
0 0 MASQUERADE 0 -- * eth0 0.0.0.0/0 0.0.0.0/0
Command (host):
sudo iptables -S FORWARD | head -3
Output:
-P FORWARD ACCEPT
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
| Output | Meaning |
|---|---|
iptables: Bad rule ... |
This is not an error. The command tried to remove an old rule, and there was no old rule. |
ip_forward is 1 |
The host passes packets between its network cards. |
MASQUERADE ... eth0 |
Our NAT rule exists. Its packet counter is 0 at this time. |
The docker0 rule and the DOCKER-USER and DOCKER-FORWARD chains |
These are the rules of Docker. We did not change them. |
Remember the packet counter of our NAT rule. In Step 20, it shows that the guest traffic goes through this rule.
Step 11: Set the logger (terminal 2)
First, tell Firecracker where to write its log. The log shows the result of each API request.
Commands (terminal 2, host):
API_SOCKET="/tmp/firecracker.socket"
LOGFILE="./firecracker.log"
sudo curl -X PUT --unix-socket "${API_SOCKET}" \
--data "{
\"log_path\": \"${LOGFILE}\",
\"level\": \"Debug\",
\"show_level\": true,
\"show_log_origin\": true
}" \
"http://localhost/logger"
Output: none (success).
Command (host, typo):
s -l firecracker.log
Output:
s: command not found
Command (host):
ls -l firecracker.log
Output:
-rw-r--r-- 1 root root 332 Oct 12 10:40 firecracker.log
When the request is successful, curl prints nothing. The log file proves the result in the next step. The line s -l is a typo for ls -l.
Step 12: Set the boot source (terminal 2)
Tell Firecracker which kernel to start, and give the kernel its boot arguments.
Command (host):
cat firecracker.log
Output:
2026-10-12T10:40:37.377715625 [anonymous-instance:fc_api:INFO:src/firecracker/src/api_server/parsed_request.rs:192] The request was executed successfully. Status code: 204 No Content.
2026-10-12T10:40:37.377782255 [anonymous-instance:fc_api:DEBUG:src/firecracker/src/api_server/mod.rs:116] Total previous API call duration: 251 us.
Commands (host):
KERNEL="./$(ls vmlinux* | tail -1)"
KERNEL_BOOT_ARGS="console=ttyS0 reboot=k panic=1"
echo "$KERNEL"
echo "$KERNEL_BOOT_ARGS"
Output:
./vmlinux-6.18.51
console=ttyS0 reboot=k panic=1
Command (host):
sudo curl -X PUT --unix-socket "${API_SOCKET}" \
--data "{
\"kernel_image_path\": \"${KERNEL}\",
\"boot_args\": \"${KERNEL_BOOT_ARGS}\"
}" \
"http://localhost/boot-source"
Output: none (success).
The log shows 204 No Content for the logger request. That request took 251 microseconds.
The boot argument console=ttyS0 sends the guest console to the serial port. Terminal 1 shows this console.
Step 13: Attach the root disk (terminal 2)
Give the guest the ext4 file as its root disk. It is writable.
Commands (host):
ROOTFS="./$(ls *.ext4 | tail -1)"
echo "$ROOTFS"
Output:
./ubuntu-24.04.ext4
Command (host):
sudo curl -X PUT --unix-socket "${API_SOCKET}" \
--data "{
\"drive_id\": \"rootfs\",
\"path_on_host\": \"${ROOTFS}\",
\"is_root_device\": true,
\"is_read_only\": false
}" \
"http://localhost/drives/rootfs"
Output: none (success).
Command (host):
cat firecracker.log
Output:
2026-10-12T10:40:37.377715625 [anonymous-instance:fc_api:INFO:src/firecracker/src/api_server/parsed_request.rs:192] The request was executed successfully. Status code: 204 No Content.
2026-10-12T10:40:37.377782255 [anonymous-instance:fc_api:DEBUG:src/firecracker/src/api_server/mod.rs:116] Total previous API call duration: 251 us.
2026-10-12T10:42:05.698480243 [anonymous-instance:fc_api:INFO:src/firecracker/src/api_server/parsed_request.rs:76] The API server received a Put request on "/boot-source" with body "{\n \"kernel_image_path\": \"./vmlinux-6.18.51\",\n \"boot_args\": \"console=ttyS0 reboot=k panic=1\"\n }".
2026-10-12T10:42:05.698807570 [anonymous-instance:fc_api:INFO:src/firecracker/src/api_server/parsed_request.rs:192] The request was executed successfully. Status code: 204 No Content.
2026-10-12T10:42:05.698858009 [anonymous-instance:fc_api:DEBUG:src/firecracker/src/api_server/mod.rs:116] Total previous API call duration: 386 us.
2026-10-12T10:42:39.159026342 [anonymous-instance:fc_api:INFO:src/firecracker/src/api_server/parsed_request.rs:76] The API server received a Put request on "/drives/rootfs" with body "{\n \"drive_id\": \"rootfs\",\n \"path_on_host\": \"./ubuntu-24.04.ext4\",\n \"is_root_device\": true,\n \"is_read_only\": false\n }".
2026-10-12T10:42:39.159163752 [anonymous-instance:fc_api:INFO:src/firecracker/src/api_server/parsed_request.rs:192] The request was executed successfully. Status code: 204 No Content.
2026-10-12T10:42:39.159175242 [anonymous-instance:fc_api:DEBUG:src/firecracker/src/api_server/mod.rs:116] Total previous API call duration: 168 us.
Step 14: Add the network card (terminal 2)
Connect a guest network card to tap0, and give it a MAC address.
Commands (host):
FC_MAC="06:00:AC:10:00:02"
TAP_DEV="tap0"
sudo curl -X PUT --unix-socket "${API_SOCKET}" \
--data "{
\"iface_id\": \"net1\",
\"guest_mac\": \"$FC_MAC\",
\"host_dev_name\": \"$TAP_DEV\"
}" \
"http://localhost/network-interfaces/net1"
Output: none (success).
Command (host):
tail -4 firecracker.log
Output:
2026-10-12T10:42:39.159175242 [anonymous-instance:fc_api:DEBUG:src/firecracker/src/api_server/mod.rs:116] Total previous API call duration: 168 us.
2026-10-12T10:43:12.319095612 [anonymous-instance:fc_api:INFO:src/firecracker/src/api_server/parsed_request.rs:76] The API server received a Put request on "/network-interfaces/net1" with body "{\n \"iface_id\": \"net1\",\n \"guest_mac\": \"06:00:AC:10:00:02\",\n \"host_dev_name\": \"tap0\"\n }".
2026-10-12T10:43:12.319790935 [anonymous-instance:fc_api:INFO:src/firecracker/src/api_server/parsed_request.rs:192] The request was executed successfully. Status code: 204 No Content.
2026-10-12T10:43:12.319810675 [anonymous-instance:fc_api:DEBUG:src/firecracker/src/api_server/mod.rs:116] Total previous API call duration: 723 us.
The log proves each request
| API request | Result | Duration |
|---|---|---|
PUT /logger |
204 No Content | 251 µs |
PUT /boot-source |
204 No Content | 386 µs |
PUT /drives/rootfs |
204 No Content | 168 µs |
PUT /network-interfaces/net1 |
204 No Content | 723 µs |
Each request took less than 1 millisecond.
How the MAC address gives the IP address
The MAC address 06:00:AC:10:00:02 contains the guest IP address. A script in the guest image reads the last four bytes of the MAC address.
| Hex byte | Decimal |
|---|---|
AC |
172 |
10 |
16 |
00 |
0 |
02 |
2 |
Thus, the guest gets the IP address 172.16.0.2. Step 16 shows this address in the guest.
Step 15: Start the VM
Send the last request: InstanceStart. The guide waits 15 milliseconds before this request.
Commands (terminal 2, host):
sleep 0.015s
sudo curl -X PUT --unix-socket "${API_SOCKET}" \
--data "{
\"action_type\": \"InstanceStart\"
}" \
"http://localhost/actions"
The output of this command in terminal 2 was not recorded. Terminal 1 showed the guest console. The recorded output starts in the middle of a line:
Output (terminal 1, guest console):
OK ] Finished fcnet.service.
[ OK ] Finished getty-static.service - ge… dbus and logind are not available.
[ OK ] Started getty@tty6.service - Getty on tty6.
[ OK ] Reached target getty.target - Login Prompts.
[ OK ] Reached target multi-user.target - Multi-User System.
[ OK ] Reached target graphical.target - Graphical Interface.
Starting systemd-update-utmp-runle…- Record Runlevel Change in UTMP...
[ OK ] Finished systemd-update-utmp-runle…e - Record Runlevel Change in UTMP.
Ubuntu 24.04.5 LTS ubuntu-fc-uvm ttyS0
ubuntu-fc-uvm login: root (automatic login)
Welcome to Ubuntu 24.04.5 LTS (GNU/Linux 6.18.51+ x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, you can run the 'unminimize' command.
The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.
root@ubuntu-fc-uvm:~#
The microVM booted. The guest runs the kernel 6.18.51, and the prompt is now root@ubuntu-fc-uvm. The serial console logs in as root automatically.
The service fcnet.service is the script in the guest image that sets the IP address from the MAC address.
Step 16: Look inside the guest (terminal 1)
Command (guest):
free -h
Output:
total used free shared buff/cache available
Mem: 104Mi 33Mi 33Mi 892Ki 43Mi 70Mi
Swap: 0B 0B 0B
Command (guest):
mount | grep "/"
Output:
/dev/vda on / type ext4 (rw,relatime)
devtmpfs on /dev type devtmpfs (rw,relatime,size=51408k,nr_inodes=12852,mode=755)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)
securityfs on /sys/kernel/security type securityfs (rw,nosuid,nodev,noexec,relatime)
selinuxfs on /sys/fs/selinux type selinuxfs (rw,nosuid,noexec,relatime)
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000)
tmpfs on /run type tmpfs (rw,nosuid,nodev,size=21316k,nr_inodes=819200,mode=755)
tmpfs on /run/lock type tmpfs (rw,nosuid,nodev,noexec,relatime,size=5120k)
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)
none on /sys/fs/pstore type pstore (rw,nosuid,nodev,noexec,relatime)
bpf on /sys/fs/bpf type bpf (rw,nosuid,nodev,noexec,relatime,mode=700)
systemd-1 on /proc/sys/fs/binfmt_misc type autofs (rw,relatime,fd=32,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=677)
mqueue on /dev/mqueue type mqueue (rw,nosuid,nodev,noexec,relatime)
hugetlbfs on /dev/hugepages type hugetlbfs (rw,nosuid,nodev,relatime,pagesize=2M)
debugfs on /sys/kernel/debug type debugfs (rw,nosuid,nodev,noexec,relatime)
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,nr_inodes=1048576)
tmpfs on /var/lib/systemd type tmpfs (rw,nosuid,nodev,nr_inodes=10240)
fusectl on /sys/fs/fuse/connections type fusectl (rw,nosuid,nodev,noexec,relatime)
binfmt_misc on /proc/sys/fs/binfmt_misc type binfmt_misc (rw,nosuid,nodev,noexec,relatime)
Command (guest):
lsblk
Output:
NAME
MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
vda 254:0 0 1G 0 disk /
Command (guest):
ip addr show eth0
Output:
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 06:00:ac:10:00:02 brd ff:ff:ff:ff:ff:ff
inet 172.16.0.2/30 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::400:acff:fe10:2/64 scope link
valid_lft forever preferred_lft forever
Command (guest):
ip route
Output:
172.16.0.0/30 dev eth0 proto kernel scope link src 172.16.0.2
| Output | Meaning |
|---|---|
Mem: 104Mi total |
The VM has 128 MiB of RAM. The kernel keeps a part of it, so the guest sees 104 MiB. |
/dev/vda on / type ext4 (rw) |
The root disk is the virtio-blk device. It is our ext4 file. |
vda ... 1G disk / |
The 1 GB file shows as a disk in the guest. |
eth0 ... 06:00:ac:10:00:02 and inet 172.16.0.2/30 |
The guest has the MAC address that we set, and the IP address from it. |
ip route shows only 172.16.0.0/30 |
The guest has no default route yet. Step 19 adds it. |
tmpfs on /tmp |
The folder /tmp is in RAM. |
We did not record the output of nproc in the guest.
Step 17: Check the machine settings (terminal 2)
Ask the API for the machine configuration:
Command (terminal 2, host):
sudo curl -s --unix-socket "${API_SOCKET}" http://localhost/machine-config | jq
Output:
{
"vcpu_count": 1,
"mem_size_mib": 128,
"smt": false,
"track_dirty_pages": false,
"huge_pages": "None"
}
We did not set the CPU count or the memory size. Thus, Firecracker used its defaults: 1 vCPU and 128 MiB. To change them, send PUT /machine-config before InstanceStart.
Step 18: Log in over SSH
The SSH login failed two times before it worked. Each failure had a clear error, and a simple fix.
18.1 First attempt: the key file was empty
Commands (host):
KEY_NAME=./$(ls *.id_rsa | tail -1)
echo "$KEY_NAME"
Output:
./ubuntu-24.04.id_rsa
Command (host):
ssh -i $KEY_NAME root@172.16.0.2 hostname
Output:
The authenticity of host '172.16.0.2 (172.16.0.2)' can't be established.
ED25519 key fingerprint is SHA256:CFmF+BYjJ9iZO2s/kvQKuCJZY/sb50E6MZFzb/9OkWs.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '172.16.0.2' (ED25519) to the list of known hosts.
Load key "./ubuntu-24.04.id_rsa": error in libcrypto
root@172.16.0.2's password:
This output tells two things:
- The network from the host to the guest works, because SSH reached the guest and received its host key.
- SSH could not read the private key (
error in libcrypto). Thus, it asked for a password.
18.2 Find the cause
Commands (host):
head -1 ubuntu-24.04.id_rsa
tail -1 ubuntu-24.04.id_rsa
wc -c ubuntu-24.04.id_rsa
tail -c 40 ubuntu-24.04.id_rsa | xxd | tail -3
Output:
0 ubuntu-24.04.id_rsa
Command (host):
ssh-keygen -y -f ubuntu-24.04.id_rsa
Output:
Load key "ubuntu-24.04.id_rsa": error in libcrypto
The private key file had 0 bytes. In Step 5.1, it had 411 bytes. We do not know the cause.
18.3 Make a new key pair
The recorded output starts at the end of the ssh-keygen output:
Output of ssh-keygen (end only):
SHA256:RIBdQmqkV49KiS03vWuVuTDbaOG2M6s4pYwP5K65m+g root@microvm-test
The key's randomart image is:
+--[ED25519 256]--+
| .+=oo |
| =.=.= |
| + X o o |
| * o o o |
| . . = S |
|o .. X . |
|.+ o O o |
|+++. +o. |
|XEo...o+ |
+----[SHA256]-----+
Command (host):
ls -l ubuntu-24.04.id_rsa*
Output:
-rw------- 1 root root 411 Oct 12 10:48 ubuntu-24.04.id_rsa
-rw------- 1 root root 0 Oct 12 10:31 ubuntu-24.04.id_rsa.empty
-rw-r--r-- 1 root root 99 Oct 12 10:48 ubuntu-24.04.id_rsa.pub
The new private key has 411 bytes. We kept the empty file as ubuntu-24.04.id_rsa.empty.
The empty file has the time 10:31. This is the same minute as truncate and mkfs.ext4 in Step 5.2. The cause is still unknown.
18.4 Second problem: the key went to the wrong machine
We added the new public key on the host (prompt root@microvm-test). It must be in the guest.
Command (host):
cat /root/.ssh/authorized_keys
Output:
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDlwVYnLJeEgNlj6KGLxkx...(long key shortened in this post)... ajay@Ajays-MacBook-Pro.local
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHuCNbO1xEMO6Id6QeQfdhCtOgLomdgOwN4/cRo8VJA+ root@microvm-test
Command (host):
ssh -i ./ubuntu-24.04.id_rsa root@172.16.0.2 hostname
Output:
root@172.16.0.2's password:
The guest did not have the new key, so SSH asked for a password again. We removed the extra key from the file on the host. The output of that cleanup was not recorded.
Lesson: Look at the prompt before you run a command.
root@microvm-testis the host.root@ubuntu-fc-uvmis the guest.
18.5 Login works
We added the new public key in the guest, from terminal 1. Then this command worked from the host:
Command (host):
ssh -i ./ubuntu-24.04.id_rsa root@172.16.0.2 hostname
Output:
ubuntu-fc-uvm
SSH printed the host name of the guest: ubuntu-fc-uvm.
Step 19: Give the guest a route to the internet
In Step 16, the guest had no default route. Thus, the first ping failed. We added a route through the host, and the ping worked.
Command (host, runs in the guest over SSH):
ssh -i ./ubuntu-24.04.id_rsa root@172.16.0.2 "ping -c 2 -W 2 8.8.8.8"
Output:
ping: connect: Network is unreachable
Command (host, runs in the guest over SSH):
ssh -i ./ubuntu-24.04.id_rsa root@172.16.0.2 "ip route add default via 172.16.0.1 dev eth0"
Output: none.
Command (host, runs in the guest over SSH):
ssh -i ./ubuntu-24.04.id_rsa root@172.16.0.2 "ping -c 2 -W 2 8.8.8.8"
Output:
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=117 time=1.92 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=117 time=1.16 ms
--- 8.8.8.8 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 1.158/1.541/1.924/0.383 ms
| Moment | Result |
|---|---|
| Before the route | Network is unreachable |
| After the route | 2 packets sent, 2 received, 0% packet loss, average 1.541 ms |
Note: This route is not permanent. The guest loses it when it reboots.
Step 20: Give the guest DNS
The guest can reach IP addresses now. It also needs a name server to find host names.
Command (host):
sudo iptables -t nat -L POSTROUTING -n -v
Output:
Chain POSTROUTING (policy ACCEPT 100 packets, 7235 bytes)
pkts bytes target prot opt in out source destination
0 0 MASQUERADE 0 -- * !docker0 172.17.0.0/16 0.0.0.0/0
10 964 MASQUERADE 0 -- * eth0 0.0.0.0/0 0.0.0.0/0
Commands (host, run in the guest over SSH):
ssh -i ./ubuntu-24.04.id_rsa root@172.16.0.2 "echo 'nameserver 8.8.8.8' > /etc/resolv.conf"
ssh -i ./ubuntu-24.04.id_rsa root@172.16.0.2 "echo 'options single-request-reopen' >> /etc/resolv.conf"
Output: none.
Command (host, runs in the guest over SSH):
ssh -i ./ubuntu-24.04.id_rsa root@172.16.0.2 "cat /etc/resolv.conf; getent hosts google.com; curl -sI https://example.com | head -1"
Output:
nameserver 8.8.8.8
options single-request-reopen
2607:f8b0:4006:805::200e google.com
HTTP/2 200
| Output | Meaning |
|---|---|
MASQUERADE ... eth0 shows 10 packets and 964 bytes |
The guest packets went through our NAT rule. In Step 10, the counter was 0. |
resolv.conf has two lines |
The DNS settings are in the guest. |
getent hosts google.com prints an address |
DNS works. The answer is an IPv6 address, but the guest has only an IPv4 route. |
HTTP/2 200 |
The guest reached a web site over HTTPS. |
The microVM is now on the internet.
Problems that we found
| Step | Problem | Fix or result |
|---|---|---|
| 1 | Typos: /dev\|kvm and systemd-detect=virt |
No harm. We typed the commands again. |
| 4.1 | cp ran before unsquashfs made squashfs-root |
We unpacked the image first. |
| 11 | Typo: s -l in place of ls -l |
No harm. |
| 18.1 to 18.3 | The private key file became 0 bytes. The cause is unknown. | We made a new key pair. |
| 18.4 | The public key went to the host, not to the guest | We removed it from the host and added it in the guest. |
| 19 | The guest had no default route | We added the route by hand. |
| 20 | DNS returned an IPv6 address first | curl still worked over IPv4. |
Not tested in this run
| Item | Status |
|---|---|
An Alpine Dockerfile, build-rootfs.sh, and microvm.sh in our work repository |
Written, but not run on the server. Only a syntax check ran on the scripts. |
| Two VMs at the same time | Not tested |
A start with --config-file |
Not tested |
| Snapshots | Not tested |
| The jailer | Not tested |
Stop the VM with reboot, and remove tap0 and the NAT rule |
Not recorded |
nproc in the guest |
Not recorded |
| Changes to other repositories | None made |
Comments (0)
No comments yet. Be the first to share your thoughts.
Leave a comment