Request change

How We Started a Firecracker MicroVM by Hand: Every Command and Every Output

Firecracker microVM by hand on an AWS cloud server, and we recorded every command and every output. The post follows the official guide step by step: check KVM, download a tested kernel and disk image, build an ext4 root file system, and configure the VM with five API calls. It also shows our real mistakes and their fixes, from an empty SSH key file to a missing default route, until the guest reached the internet

How We Started a Firecracker MicroVM by Hand: Every Command and Every Output

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

  1. How to check that a server can run KVM.
  2. How to get a tested guest kernel and a guest disk image from the Firecracker CI bucket.
  3. How to build an ext4 root file system with an SSH key in it.
  4. How to start Firecracker and configure a microVM with its REST API.
  5. How to give the guest a network, a default route, and DNS.
  6. 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-test is the host. root@ubuntu-fc-uvm is 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
Share
Like this post?

Request a change or update

Suggest a correction or content update. The post author or an admin will be notified and can resolve or respond.

Comments (0)

No comments yet. Be the first to share your thoughts.

Leave a comment