Lesson  in  Hands-On Firecracker: microVMs From First Principles to Production

Run Your Second Firecracker microVM: REST API Way

Why Use the Firecracker API?

In the previous lesson, we booted a microVM by handing Firecracker a single JSON file with --config-file and explicitly disabling the API with --no-api. That mode is great for getting started: you point at a kernel and a rootfs, and Firecracker starts a VM in one go. But once the guest is running, Firecracker stops listening to you. The only "control" you have over the live VM is sending the termination signal to its host process.

The API mode is the other end of the spectrum. You launch firecracker first, without any configuration; it opens a Unix domain socket and waits for HTTP requests. You then control every step of the lifecycle - from configuring the boot source to gracefully shutting the guest down - by sending small JSON payloads to that socket. It's the same VMM, but exposed as a tiny REST service instead of a single CLI invocation.

This is the path most real-world systems take. AWS Lambda, Fly.io, E2B, and iximiuz Labs all speak to Firecracker over its API. Regardless of whether you use curl or consume the API via the official Go SDK, the mental model you build here is exactly the one you'll need when you start using Firecracker in production.

What you can do over the API that you can't do via the config file

The Firecracker REST API is small - a couple of dozen endpoints - but it covers the entire lifecycle of a microVM. A few things you can do with it that simply have no equivalent in the --no-api mode:

  • Inspect the live machine. GET / returns the current state (Running, Paused, ...) and instance metadata, and GET /vm/config dumps the full effective configuration of the running guest.
  • Pause and resume execution. PATCH /vm flips the VM between Paused and Resumed - vCPUs stop scheduling, but the guest's memory is kept intact. This is how snapshotting works under the hood, and how platforms like Fly.io implement "stopped but warm" instances.
  • Snapshot and restore. PUT /snapshot/create writes the current memory and machine state to disk, and PUT /snapshot/load boots a fresh microVM straight from that snapshot - skipping kernel boot entirely. That's what enables sub-100 ms cold starts in production microVM platforms.
  • Hot-update devices. Once the guest is running, PATCH /drives/{id} can swap a backing file (handy for CI sandboxes that hand fresh rootfses to short-lived jobs), and PATCH /network-interfaces/{id} can re-tune rate limiters on the fly.
  • Graceful shutdown. PUT /actions with SendCtrlAltDel asks the guest to power off cleanly through ACPI - much friendlier than killing the host process.
  • Hot(un)plug memory and tune ballooning. PATCH /hotplug/memory resizes a virtio-mem region the guest sees as live RAM, and PATCH /balloon tells the in-guest balloon driver to give back or take memory. Both let the host change a guest's working set without rebooting it.
  • Read out runtime telemetry. GET /balloon/statistics exposes the guest's memory accounting, and PUT /metrics and PUT /logger wire up structured logs and metrics streams.

We're keeping things minimal again. No networking, no SSH, no extra features - just the kernel, a rootfs, and a Firecracker process listening on the API socket. Every networking-related step we'll skip here gets its own dedicated lesson later in the course.

Start Firecracker in API Mode

Before we can send any API requests, we need a Firecracker process listening on a socket. Unlike the config-file mode, launching firecracker does NOT start the VM - it just opens the socket and waits for instructions.

Everything we need from the previous lesson - the firecracker binary, the kernel, the rootfs - has already been prepared for you under ~/vm/:

ls -lh ~/vm
-rw-r--r-- 1 laborant laborant 1.0G   rootfs.ext4
-rwxr-xr-x 1 laborant laborant  ...   vmlinux.bin

If you'd like to refresh how these artifacts were produced, jump back to the previous lesson - the steps are exactly the same as before.

Now let's launch Firecracker in API mode. Keep this terminal open - the firecracker process will run in the foreground and the guest's serial console will eventually attach to it:

Terminal 1
sudo firecracker --api-sock ~/vm/firecracker.sock

You should see no output - that's intended. Firecracker is now sitting on the Unix socket waiting for the first PUT request. Compared to the config-file mode, nothing about the VM has been decided yet: no kernel, no drives, no vCPU count.

In a separate terminal, you can confirm the socket is there:

Terminal 2
ls -lh ~/vm/firecracker.sock
srwxr-xr-x 1 root root 0 ... /home/laborant/vm/firecracker.sock

The leading s in the file mode means it's a Unix domain socket. We'll be talking to it for the rest of the lesson with curl --unix-socket.

A quick sanity check - the API responds even before the VM is configured:

Terminal 2
curl --silent --unix-socket ~/vm/firecracker.sock \
    http://localhost/ | jq
{
  "app_name": "Firecracker",
  "id": "anonymous-instance",
  "state": "Not started",
  "vmm_version": "1.15.1"
}

The state: "Not started" confirms we're pre-boot. Let's change that next.

Configure the microVM Over the API

Now that Firecracker is listening, we'll send it the same three pieces of information that lived in the JSON config file last lesson - boot source, root drive, and machine config - except this time each one becomes a separate PUT request to the corresponding endpoint.

All commands in this unit go in Terminal 2, while Firecracker keeps running in Terminal 1.

To save typing, define a small helper that wraps curl with the right flags:

Terminal 2
SOCK=/home/laborant/vm/firecracker.sock

fc_api() {
  local method="$1" path="$2" data="$3"
  sudo curl --silent --show-error --fail \
    --unix-socket "${SOCK}" \
    -X "${method}" "http://localhost${path}" \
    -H 'Accept: application/json' \
    -H 'Content-Type: application/json' \
    -d "${data}"
}

The http://localhost part is just a placeholder - HTTP requires a host header but the actual transport here is the Unix socket, not TCP. The path after localhost is what selects the API endpoint. We use sudo because the socket was created by the sudo firecracker process from Terminal 1.

1. Set the boot source

The /boot-source endpoint tells Firecracker which kernel to load and which command-line arguments to pass to it. The kernel command-line is the exact same string we wrote into boot_args in the JSON config last lesson:

Terminal 2
fc_api PUT /boot-source '{
  "kernel_image_path": "/home/laborant/vm/vmlinux.bin",
  "boot_args": "console=ttyS0 reboot=k panic=1"
}'

2. Attach the root drive

Next, PUT /drives/{drive_id} adds a block device backed by the rootfs ext4 file. The drive_id is just a label we choose - we'll call it rootfs:

Terminal 2
fc_api PUT /drives/rootfs '{
  "drive_id": "rootfs",
  "path_on_host": "/home/laborant/vm/rootfs.ext4",
  "is_root_device": true
}'

You can attach more than one drive - just PUT /drives/<another-id> with a different path_on_host. Only one drive is allowed to have is_root_device: true, though.

3. Set the machine resources

The /machine-config endpoint defines the vCPU count and memory size - the same machine-config block we had in the JSON file:

Terminal 2
fc_api PUT /machine-config '{
  "vcpu_count": 2,
  "mem_size_mib": 1024
}'

At this point, Firecracker has all the pieces it needs to boot, but the VM is still not running - we haven't told it to start yet. You can verify that yourself:

Terminal 2
curl --silent --unix-socket "${SOCK}" http://localhost/ | jq -r '.state'
Not started

In the next unit, we flip that switch.

Boot the microVM

With the boot source, root drive, and machine config in place, the only thing left to do is to fire the start action. Firecracker exposes lifecycle actions through a single endpoint - PUT /actions - and uses the action_type field to discriminate between them. The relevant ones for now are InstanceStart (boot the VM) and SendCtrlAltDel (graceful shutdown).

From Terminal 2, send the start action:

Terminal 2
fc_api PUT /actions '{"action_type":"InstanceStart"}'

The request returns immediately with a 204 No Content - and almost at the same instant, Terminal 1 starts streaming kernel boot messages as the guest comes up. The serial console is attached to Firecracker's stdout, exactly like in the config-file mode.

After a brief flurry of messages, you should see the OpenRC banner and an Alpine login prompt in Terminal 1:

Terminal 1
Welcome to Alpine Linux 3.23
Kernel 6.18.21 on x86_64 (/dev/ttyS0)

secondvm login:

Log in as root with password root and take a look around:

Terminal 1
uname -a
Terminal 1
cat /etc/alpine-release
Terminal 1
cat /etc/hostname
Terminal 1
free -m
Terminal 1
nproc
Terminal 1
ls /
Terminal 1
ps aux

The output should look essentially identical to what you saw in the previous lesson - same kernel, same rootfs, same shape. The only thing that changed is how we got here.

We deliberately did not configure a network interface, so the guest has only the loopback device. We'll cover networking via the API in the upcoming networking lessons - and once you've seen this lesson, those PUT requests will feel familiar.

Keep both terminals open - in the next units we'll start poking the running VM through the API while it's alive.

Inspect the Live microVM

With the VM running and Firecracker still listening on the socket, we can ask it questions about itself. From Terminal 2 (Terminal 1 is still attached to the guest console):

Instance info

GET / returns a small "header" object that summarizes the microVM's lifecycle state:

Terminal 2
curl --silent --unix-socket "${SOCK}" http://localhost/ | jq
{
  "app_name": "Firecracker",
  "id": "anonymous-instance",
  "state": "Running",
  "vmm_version": "1.15.1"
}

The state field is the most useful one in practice. It's how a control plane checks whether a microVM is Running, Paused, or hasn't been started yet. We'll come back to this field in the next unit.

VMM version

If you only need the Firecracker version, /version is a focused endpoint:

Terminal 2
curl --silent --unix-socket "${SOCK}" http://localhost/version | jq
{
  "firecracker_version": "1.15.1"
}

Effective machine configuration

GET /machine-config returns whatever Firecracker actually accepted for the machine shape - including any defaults that were filled in for fields you didn't explicitly set:

Terminal 2
curl --silent --unix-socket "${SOCK}" http://localhost/machine-config | jq
{
  "vcpu_count": 2,
  "mem_size_mib": 1024,
  "smt": false,
  "track_dirty_pages": false,
  "huge_pages": "None"
}

Full effective configuration

GET /vm/config is the heavy-duty cousin - it returns the entire effective configuration as a single JSON document, including all sections (boot-source, drives, machine-config, network interfaces, etc.):

Terminal 2
curl --silent --unix-socket "${SOCK}" http://localhost/vm/config | jq
{
  "boot-source": {
    "kernel_image_path": "/home/laborant/vm/vmlinux.bin",
    "boot_args": "console=ttyS0 reboot=k panic=1",
    "initrd_path": null
  },
  "drives": [
    {
      "drive_id": "rootfs",
      "path_on_host": "/home/laborant/vm/rootfs.ext4",
      "is_root_device": true,
      "is_read_only": false,
      ...
    }
  ],
  "machine-config": {
    "vcpu_count": 2,
    "mem_size_mib": 1024,
    ...
  },
  "network-interfaces": [],
  ...
}

This is what you'd dump from a control plane to record exactly how a particular guest was provisioned - useful for debugging "wait, what was attached to that VM?" tickets, and for building snapshot-equivalent configurations when restoring a guest on another host.

Try filtering for individual sections with jq:

Terminal 2
curl --silent --unix-socket "${SOCK}" http://localhost/vm/config | jq '."boot-source"'
Terminal 2
curl --silent --unix-socket "${SOCK}" http://localhost/vm/config | jq '.drives'

These read-only endpoints are the closest Firecracker comes to a "show me everything you know" command. Internalize them - in the Go SDK lesson and the production-style examples later in the course, this is how you'll reconcile what should be true about a microVM with what actually is.

Pause and Resume the microVM

A microVM is a process from the host's point of view, but it's also a guest with vCPUs, memory, and devices that the VMM can stop and re-arm at will. The API mode lets us freeze a running guest in place and then thaw it later - all without touching the underlying firecracker host process.

This is what PATCH /vm is for. It takes a single field, state, with two valid values:

  • Paused - stop scheduling vCPUs, keep memory and device state intact.
  • Resumed - resume vCPU scheduling.

Pause the guest

From Terminal 2, freeze the VM:

Terminal 2
fc_api PATCH /vm '{"state":"Paused"}'

The request returns immediately. Switch over to Terminal 1 - the serial console is still attached, but now it's completely unresponsive. Hit Enter a few times: nothing happens. The guest's vCPUs aren't being scheduled, so no command, kernel timer, or output thread can make progress.

Confirm the state through the API:

Terminal 2
curl --silent --unix-socket "${SOCK}" http://localhost/ | jq -r '.state'
Paused

A paused microVM still owns its memory on the host. From the host's perspective, the firecracker process is still alive and its RSS hasn't shrunk. What changed is that no vCPU thread is being scheduled - so the guest sees time as if it has stopped.

This is the exact primitive that snapshotting builds on: pause first, then PUT /snapshot/create to dump the frozen state to disk. We'll see that pattern in a later lesson.

Resume the guest

Now flip it back:

Terminal 2
fc_api PATCH /vm '{"state":"Resumed"}'

Switch back to Terminal 1 - the prompt is alive again, and any keys you type now reach the guest. Time inside the guest also resumes from where it left off; from the guest's perspective, no time elapsed during the pause:

Terminal 1
date

Verify through the API too:

Terminal 2
curl --silent --unix-socket "${SOCK}" http://localhost/ | jq -r '.state'
Running

This pause/resume primitive is small, but it's the foundation under most microVM-based platforms. Sub-second cold starts, "warm pools" of suspended VMs, snapshots, live migration - they all start with a PATCH /vm.

Gracefully Shut the microVM Down

In the previous lesson we shut the microVM down by typing reboot from inside the guest. That works - but it requires getting into the guest first. With the API mode, we can ask the guest to power off from the outside, without ever touching the console.

The mechanism is PUT /actions with action_type: "SendCtrlAltDel". Despite the name, it doesn't literally send a key sequence - on x86 microVMs, Firecracker raises an ACPI shutdown event, and the guest's init system reacts to it the same way it would react to a power button press on a real machine.

Send the action from Terminal 2:

Terminal 2
fc_api PUT /actions '{"action_type":"SendCtrlAltDel"}'

Switch to Terminal 1: you should see OpenRC's shutdown sequence run through (stopping local services, unmounting /, syncing disks), followed by the kernel's reboot poweroff message. Because we set reboot=k panic=1 and Firecracker doesn't actually support reboot, the host process exits cleanly:

Terminal 1
 * Stopping local ... [ ok ]
 * Unmounting loop devices
 * Unmounting filesystems
 ...
[   42.130991] reboot: Restarting system
[   42.131488] reboot: machine restart

The Firecracker process in Terminal 1 has now exited, and the API socket is gone. Confirm from Terminal 2:

Terminal 2
ls /home/laborant/vm/firecracker.sock 2>&1
ls: cannot access '/home/laborant/vm/firecracker.sock': No such file or directory
Terminal 2
pgrep -fa firecracker || echo "no firecracker process"
no firecracker process

Why this matters in practice. Sending SIGKILL (or just unplugging the host) would also stop the guest, but it would leave the rootfs in whatever state it happened to be in - dirty filesystem journals, half-written log files, broken application state. SendCtrlAltDel lets the guest's init system run its shutdown hooks, so files are flushed, daemons get a chance to deregister, and the rootfs ext4 image is cleanly unmounted.

For workloads that care about durability - databases, queues, anything stateful - always prefer the graceful path.

There's also a "harder" option: PUT /actions doesn't have a force-shutdown action, so if the guest hangs and doesn't react to SendCtrlAltDel, you fall back to terminating the host firecracker process (SIGTERM or SIGKILL). That's the equivalent of pulling the power - useful, but not your default.

Summary

We booted the same microVM as in the previous lesson - same kernel, same rootfs, same shape - but instead of a single static config file, we drove every step through Firecracker's REST API on a Unix socket.

To recap the lifecycle:

  1. Started Firecracker in server mode - firecracker --api-sock <socket> opens the Unix socket and waits, with no VM configured yet.
  2. Configured the boot source, root drive, and machine shape - three PUT requests (/boot-source, /drives/{id}, /machine-config) carrying the same JSON shape we had in the static config file.
  3. Started the VM - PUT /actions with InstanceStart. The guest came up on Terminal 1's serial console.
  4. Inspected the live machine - GET /, GET /machine-config, GET /vm/config, GET /version. None of these have an equivalent in the config-file mode.
  5. Paused and resumed the guest - PATCH /vm with Paused/Resumed. The vCPUs stop scheduling but memory stays put - the foundation for snapshots, warm pools, and live migration.
  6. Shut the guest down gracefully - PUT /actions with SendCtrlAltDel. The guest's init system runs its shutdown hooks; the host firecracker process exits cleanly when the guest reboots.

In the next lesson, we'll boot the same VM one more time - but instead of curl, we'll go through the official Go SDK. The wire-level interactions are identical to the ones you just sent by hand; the SDK simply replaces JSON strings with typed Go structs and adds a few conveniences on top.