The Firecracker REST API: booting a microVM by hand with curl
Firecracker does not have a CLI for humans. There is no `firecracker run ubuntu`. What it has is a REST API served over a Unix domain socket, and the intended client is not you — it's a supervising program that manages a fleet of microVMs. But the fastest way to build an accurate mental model of that design is to be the supervising program for ten minutes: start the VMM, PUT some JSON at it with curl, and watch a guest kernel print to your terminal.
This is that walkthrough. We'll boot a microVM by hand, then take a snapshot of it and restore it, then step back and look at why the API is shaped the way it is — the socket instead of a port, the pre-boot/post-boot split, and what a 400 is usually telling you. I'm Ajay; I build PandaStack, which runs Firecracker microVMs as a service, so the last section is about what a platform layer adds on top. Everything before that is just the API.
The socket is the API — and the security model
The first design decision worth understanding is the one you meet before you send a single request: Firecracker listens on a Unix domain socket, not a TCP port. You start it with `--api-sock /tmp/fc.sock` and it creates that file. Every request goes through it. There is no `--api-port`, and that is not an oversight.
A TCP listener is a thing that can be exposed. It has to bind an address, which means somebody eventually binds 0.0.0.0 by accident, or a container's network namespace turns out to be less isolated than assumed, or a debugging session leaves a port open on a host that is reachable from somewhere it shouldn't be. Then you need authentication, and TLS, and a token store, and a way to rotate the tokens — a whole security apparatus bolted onto a component whose only legitimate caller lives on the same machine.
A socket file sidesteps that entire category. It has no address. It cannot be reached from the network under any misconfiguration, because there is nothing listening on the network. And the authorization model is one you already have: filesystem permissions. Whoever can open that file for read/write can control the VM. Whoever cannot, cannot. There is no separate auth layer because the operating system already shipped one, and it's the one your ops tooling already knows how to reason about.
Starting the VMM (which boots nothing)
Run `firecracker --api-sock /tmp/fc.sock` and… nothing happens. No guest, no kernel, no output beyond the process sitting there. This surprises people the first time. The Firecracker process at this point is an empty machine chassis with an API attached: it has created the socket, it is waiting for you to describe a virtual machine, and it will not start one until you explicitly tell it to.
One practical detail that will bite you within the first five minutes: Firecracker will refuse to start if the socket path already exists. It won't clobber a file it didn't create. So `rm -f /tmp/fc.sock` before every run, or use a fresh path per instance (`/tmp/fc-$$.sock` is a decent habit, and it's what you'd do anyway if you were running more than one).
Booting a microVM by hand
Here is the whole sequence. Five PUTs to describe the machine, then one PUT to start it. Note that `http://localhost` is a placeholder — with `--unix-socket`, curl ignores the host portion entirely and dials the socket; the path is the only part that matters. Note also that the tap device has to exist before you reference it, because host networking is your responsibility, not Firecracker's.
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------------------------
# 0. Host networking is YOUR job. Firecracker does not create tap
# devices, hand out DHCP leases, or NAT anything for you.
# ---------------------------------------------------------------
sudo ip tuntap add tap0 mode tap
sudo ip addr add 172.16.0.1/30 dev tap0
sudo ip link set tap0 up
# ---------------------------------------------------------------
# 1. Start the VMM. This creates the socket and waits. It has not
# booted anything and will not until you tell it to.
# ---------------------------------------------------------------
rm -f /tmp/fc.sock
firecracker --api-sock /tmp/fc.sock &
until [ -S /tmp/fc.sock ]; do sleep 0.01; done
# ---------------------------------------------------------------
# 2. Boot source: which kernel, and what to put on its cmdline.
# kernel_image_path must be an UNCOMPRESSED vmlinux. There is no
# bootloader here -- Firecracker loads and jumps into the kernel.
# ---------------------------------------------------------------
curl -s --unix-socket /tmp/fc.sock -X PUT 'http://localhost/boot-source' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"kernel_image_path": "/srv/vmlinux-5.10", "boot_args": "console=ttyS0 reboot=k panic=1 pci=off"}'
# ---------------------------------------------------------------
# 3. Root drive. The path segment after /drives/ and the drive_id in
# the body must match -- yes, you say it twice. is_root_device
# is what makes the guest cmdline root=/dev/vda work.
# ---------------------------------------------------------------
curl -s --unix-socket /tmp/fc.sock -X PUT 'http://localhost/drives/rootfs' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"drive_id": "rootfs", "path_on_host": "/srv/rootfs.ext4", "is_root_device": true, "is_read_only": false}'
# ---------------------------------------------------------------
# 4. NIC. iface_id names it inside the VMM; host_dev_name is the tap
# you created in step 0. guest_mac is optional but you want to set
# it: a stable MAC is how a guest keeps a stable identity across
# restores, and how you can derive an IP without DHCP.
# ---------------------------------------------------------------
curl -s --unix-socket /tmp/fc.sock -X PUT 'http://localhost/network-interfaces/eth0' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"iface_id": "eth0", "host_dev_name": "tap0", "guest_mac": "06:00:AC:10:00:02"}'
# ---------------------------------------------------------------
# 5. Machine shape. smt:false means no hyperthread siblings exposed
# to the guest -- on x86 it is the safer default for untrusted
# workloads. These values are FROZEN once the instance starts.
# ---------------------------------------------------------------
curl -s --unix-socket /tmp/fc.sock -X PUT 'http://localhost/machine-config' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"vcpu_count": 2, "mem_size_mib": 1024, "smt": false}'
# ---------------------------------------------------------------
# 6. Go. The vCPUs start executing the kernel. Watch the terminal
# where firecracker is running -- console=ttyS0 means kernel
# output lands on its stdout.
# ---------------------------------------------------------------
curl -s --unix-socket /tmp/fc.sock -X PUT 'http://localhost/actions' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"action_type": "InstanceStart"}'
# ---------------------------------------------------------------
# 7. Ask the VMM who it is and what it's doing. Returns something
# like {"id":"anonymous-instance","state":"Running",
# "vmm_version":"1.x.y","app_name":"Firecracker"}
# ---------------------------------------------------------------
curl -s --unix-socket /tmp/fc.sock 'http://localhost/' \
-H 'Accept: application/json'If you got kernel output, congratulations: you have manually performed every step a container runtime hides from you, and you now have a strong intuition for why nobody does this by hand twice. If you got silence, the usual culprits are a compressed kernel image, a rootfs the guest can't mount, or a `boot_args` missing `console=ttyS0` — all three fail quietly because the guest never gets far enough to complain to a console you can see.
A few things worth noticing about what just happened. Successful PUTs return 204 No Content with an empty body, so "no output" is the success case and you should be checking status codes rather than eyeballing. There was no image name anywhere — Firecracker has no registry, no concept of pulling anything; you handed it two file paths you were responsible for creating. And the ordering is real: `/actions` with `InstanceStart` is the line that divides the API into two halves.
The pre-boot / post-boot split
Firecracker's API is a state machine with exactly one interesting transition. Before `InstanceStart`, you are describing a machine that does not exist yet, and most resources are configure-once: you can PUT them, re-PUT them, change your mind. After `InstanceStart`, that machine exists and is running, and the things that define its hardware are frozen — because they correspond to a device the guest kernel has already probed and a memory map it has already accepted. You cannot hand a running Linux kernel two more vCPUs by editing JSON.
Here's the split, roughly, with what each endpoint is for:
- PUT /boot-source — pre-boot only. `kernel_image_path` (an uncompressed vmlinux), `boot_args` (the kernel cmdline), and optionally `initrd_path`. This is the closest thing Firecracker has to a bootloader, and it's a JSON object.
- PUT /drives/{drive_id} — pre-boot to add a virtio-blk device: `drive_id` (must match the path segment), `path_on_host`, `is_root_device`, `is_read_only`. Post-boot you get PATCH /drives/{drive_id} instead, which can swap the backing file path or adjust a rate limiter — useful for hot-swapping a data disk, useless for adding a new one.
- PUT /network-interfaces/{iface_id} — pre-boot to attach a virtio-net device bound to a host tap: `iface_id`, `host_dev_name`, optional `guest_mac` and rate limiters. Post-boot, PATCH can retune the rate limiters. The tap device itself must already exist on the host.
- PUT /machine-config — pre-boot only. `vcpu_count`, `mem_size_mib`, `smt`, plus optional `track_dirty_pages` (required if you want diff snapshots later) and CPU template settings. This is the hardware; it is immutable once the guest is running.
- PUT /actions — the boot trigger, via `{"action_type": "InstanceStart"}`. The same endpoint serves post-boot actions such as `SendCtrlAltDel` (the polite way to ask a guest to shut down) and `FlushMetrics`.
- PATCH /vm — post-boot only. `{"state": "Paused"}` and `{"state": "Resumed"}`. Pausing stops the vCPUs; it is the precondition for taking a snapshot.
- PUT /snapshot/create and PUT /snapshot/load — freeze a paused microVM to disk, and build a new microVM from that on a fresh, never-started VMM process. These two are mirror images and each is only legal in its own half of the lifecycle.
- GET / — legal at any time. Returns instance info: id, state ("Not started", "Running", "Paused"), and `vmm_version`, which is the honest way to find out which API you're actually coding against.
- Also worth knowing: PUT /logger and PUT /metrics point the VMM's own logs and metrics at files (set these early — they're pre-boot and you will want them the first time something fails silently), PUT /vsock attaches a virtio-vsock device for host-guest communication that doesn't involve the network, and PUT /balloon plus PUT /mmds-config cover memory ballooning and the metadata service.
Once you internalize the split, most 400s explain themselves. The three common causes are: you tried a pre-boot-only resource after `InstanceStart` (or a post-boot-only one before it), you omitted a required field, or you included a field the schema doesn't know — Firecracker rejects unknown fields rather than ignoring them, which is exactly what you want from a control API but does mean a typo in a key name is a hard failure rather than a mysterious no-op.
Snapshot and restore over the same API
The snapshot endpoints are where the API stops being a fancy config file and starts being genuinely interesting. Taking a snapshot writes out the entire guest — its physical RAM to a memory file, and the VMM state (vCPU registers, interrupt controller, clock, every virtio device's configuration) to a state file. Loading that snapshot into a fresh Firecracker process reconstructs the machine mid-instruction. There is no boot. The guest does not run init, does not re-probe devices, does not notice.
# =================================================================
# SNAPSHOT: on the RUNNING instance from the previous script.
# The VM must be paused first -- you cannot serialize vCPU state
# that is still changing underneath you.
# =================================================================
curl -s --unix-socket /tmp/fc.sock -X PATCH 'http://localhost/vm' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"state": "Paused"}'
# snapshot_type Full writes the whole guest RAM. "Diff" writes only
# dirty pages, and requires track_dirty_pages:true back in
# /machine-config -- decided before boot, which is a nice example of
# pre-boot config constraining what you can do post-boot.
curl -s --unix-socket /tmp/fc.sock -X PUT 'http://localhost/snapshot/create' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"snapshot_type": "Full", "snapshot_path": "/srv/snap/vm.state", "mem_file_path": "/srv/snap/vm.mem"}'
# Optional: carry on where you left off. Otherwise kill the process --
# you have the machine on disk now.
curl -s --unix-socket /tmp/fc.sock -X PATCH 'http://localhost/vm' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"state": "Resumed"}'
# =================================================================
# RESTORE: a BRAND NEW, never-started firecracker process. You do NOT
# re-send boot-source / drives / machine-config -- all of that is
# inside the snapshot. But the host-side resources it referenced
# (the tap device, the rootfs file) must exist again, at the same
# names, or the load fails.
# =================================================================
rm -f /tmp/fc2.sock
firecracker --api-sock /tmp/fc2.sock &
until [ -S /tmp/fc2.sock ]; do sleep 0.01; done
curl -s --unix-socket /tmp/fc2.sock -X PUT 'http://localhost/snapshot/load' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"snapshot_path": "/srv/snap/vm.state", "mem_backend": {"backend_path": "/srv/snap/vm.mem", "backend_type": "File"}, "resume_vm": false}'
# resume_vm:false loads it paused, which gives you a window to fix up
# host state before the guest runs again. Then unpause:
curl -s --unix-socket /tmp/fc2.sock -X PATCH 'http://localhost/vm' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"state": "Resumed"}'
curl -s --unix-socket /tmp/fc2.sock 'http://localhost/' -H 'Accept: application/json'
# -> state is "Running", and the guest has no idea any of this happened.Three notes on the restore path, because it's where the sharp edges live. First, `mem_backend` with `backend_type` is the modern shape; older releases used a flat `mem_file_path` on `/snapshot/load`, so if you're following a tutorial that predates your binary, this is the field that will 400 at you. Second, `backend_type` also accepts `Uffd`, which is how you hand memory-fault handling to a userspace process over a Unix socket instead of mapping a local file — that's the hook that makes streaming a memory image from object storage possible. Third, a restore is not hermetic: the snapshot remembers that it had a tap named `tap0` and a drive at a particular path, and it expects the host to still be able to provide them.
You should also assume the restored guest wakes up believing it is still the moment the snapshot was taken. Its clock is frozen at bake time, its TCP connections are stale, and any secret baked into its memory is now in every copy you restore. Those are the real costs of the trick, and they're solvable, but they're solvable by the layer above Firecracker, not by Firecracker.
The shortcut: --config-file
If the six-curl dance feels like a lot of ceremony for a one-shot boot, there's a shortcut. Firecracker accepts `--config-file` pointing at a single JSON document describing the same resources, applies it, and starts the microVM immediately — no `InstanceStart` call required. The top-level keys mirror the API paths.
{
"boot-source": {
"kernel_image_path": "/srv/vmlinux-5.10",
"boot_args": "console=ttyS0 reboot=k panic=1 pci=off"
},
"drives": [
{
"drive_id": "rootfs",
"path_on_host": "/srv/rootfs.ext4",
"is_root_device": true,
"is_read_only": false
}
],
"network-interfaces": [
{
"iface_id": "eth0",
"host_dev_name": "tap0",
"guest_mac": "06:00:AC:10:00:02"
}
],
"machine-config": {
"vcpu_count": 2,
"mem_size_mib": 1024,
"smt": false
},
"logger": {
"log_path": "/srv/logs/fc.log",
"level": "Info",
"show_level": true,
"show_log_origin": true
}
}Run it with `firecracker --api-sock /tmp/fc.sock --config-file vm.json`. The API socket still comes up and still works for everything post-boot — pausing, snapshotting, PATCHing a drive — unless you also pass `--no-api`, which starts the VM with no control surface at all. That combination is a legitimate hardening choice for a workload you intend to start, run, and kill without ever touching again: the strongest thing you can say about an API is that it isn't there.
The config file is genuinely the right call for local testing, kernel bring-up, and CI. It is the wrong call the moment you want to snapshot, fork, hot-swap a drive, or make a decision at runtime — which is to say, the moment you're running a platform rather than a test.
Why there's no nice UX (on purpose)
Every complaint about this API — the verbosity, the repeated drive_id, the empty 204s, the fact that you must build your own rootfs and tap device — dissolves once you accept who the intended caller is. It isn't a person at a keyboard. It's a supervisor process that already knows the kernel path, already allocated the network, and needs to make the same twelve decisions ten thousand times an hour without ambiguity.
For that caller, everything a human would call friction is a feature. Explicit paths mean no image resolution logic that could pick the wrong thing. Rejected unknown fields mean a bad config fails at request time instead of producing a subtly wrong VM. A state machine with one hard transition means the supervisor can model the lifecycle exactly and never has to ask "is it too late to set this?" — the API answers definitively. And no interactive layer means no hidden state, no daemon accumulating opinions, nothing between the orchestrator and the machine.
Firecracker isn't missing a nice CLI. It's assuming you're going to write one, for your specific fleet, in a language with a type checker.
What a platform layer adds on top
So you write that supervisor. Here's roughly what it turns out to need, in the order you discover you need it — this list is a compressed version of what PandaStack's per-host agent does around exactly the API calls above:
- Networking. Firecracker takes a tap name and nothing else. Somebody has to create the tap, put it in a per-VM network namespace, wire a veth pair to the host, set up NAT and firewall rules, allocate a subnet that doesn't collide with the other guests on the box, and tear all of it down atomically when the VM dies. Doing this cold costs on the order of 100ms, which is why PandaStack pre-allocates 16,384 /30 subnets per agent and keeps a warm pool of built namespaces so a create just grabs one.
- Rootfs lifecycle and copy-on-write. `path_on_host` points at a file the guest will write to, so every VM needs its own. Copying a multi-gigabyte image per create is not viable, so you clone it copy-on-write — an XFS reflink or a dm-snapshot — which makes it an O(metadata) operation regardless of image size.
- Snapshot management. Taking a snapshot is one API call; knowing which snapshot is current for which template, versioning it when the rootfs changes, replicating it to every host that might need it, garbage-collecting the old generations, and doing all of that without a fan-out race is the actual work.
- Guest communication. The API boots the machine and then has nothing to say about what's inside it. Running a command, reading a file, or streaming output needs a channel — vsock to a guest agent, or SSH — plus a readiness probe, because a resumed vCPU is not the same thing as a guest that will accept a connection.
- Lifecycle and reconciliation. TTLs, idle reaping, pause and resume, crash recovery, and the unglamorous business of noticing that a VMM process died and cleaning up the netns, the tap, the CoW clone, and the database row it left behind.
- Placement. With more than one host, something has to decide which host, based on free CPU and memory, whether that host already has the snapshot locally, and whether a fork should land next to its parent so the memory and rootfs clone stay local.
And then the punchline, which is the thing most worth taking away from the whole walkthrough: in production you almost never run the sequence you just ran. Cold-booting through `/boot-source` and `InstanceStart` is what happens once, per template, to produce a snapshot. After that, every create is `PUT /snapshot/load` followed by `PATCH /vm` with Resumed — the second script, not the first. On PandaStack that path is a p50 of 179ms and a p99 of about 203ms end to end, of which the restore itself is roughly 49ms; the cold boot it replaces takes around 3 seconds and happens exactly once. Forking a running machine is the same primitive pointed at a live sandbox instead of a template: 400–750ms on the same host, 1.2–3.5s across hosts where the artifacts have to move first.
Which means the API you'd actually spend your time with, if you built this yourself, is not the boot API. It's the snapshot API. The boot sequence is a build step.
For contrast, here's the same outcome — a hardware-isolated Linux machine with its own guest kernel, running a command — with the platform layer already written:
from pandastack import Sandbox
with Sandbox.create(template="base", ttl_seconds=300) as sbx:
print(sbx.exec("uname -a").stdout)
# Underneath: a NATID netns + tap grabbed from the warm pool, a
# reflinked rootfs, a fork+exec of firecracker, PUT /snapshot/load,
# PATCH /vm Resumed, and a readiness probe -- the exact calls from
# this post, in order, in about 179ms. Torn down on block exit.That is not an argument against learning the raw API — quite the opposite. Every one of those three lines maps onto a call you just made by hand, and knowing which one is why you'll be able to debug it when it misbehaves. It's an argument that the raw API is a substrate, and substrates are meant to be built on. Go boot one by hand once. Then let something else do it ten thousand times.
Frequently asked questions
How do I boot a Firecracker microVM with curl?
Start the VMM with `firecracker --api-sock /tmp/fc.sock`, then send five PUT requests through that socket with `curl --unix-socket`: `/boot-source` with a `kernel_image_path` (an uncompressed vmlinux) and `boot_args`, `/drives/rootfs` with `path_on_host` and `is_root_device: true`, `/network-interfaces/eth0` with `host_dev_name` pointing at a tap device you created beforehand, and `/machine-config` with `vcpu_count` and `mem_size_mib`. Finally PUT `{"action_type": "InstanceStart"}` to `/actions` to start the vCPUs. Successful calls return 204 with an empty body, so check status codes rather than looking for output. Field names vary slightly across releases — check the OpenAPI spec at firecracker/src/firecracker/swagger/firecracker.yaml for your version.
Why does Firecracker use a Unix socket instead of a TCP port for its API?
Because there is no legitimate remote caller, so a network listener would be pure risk with no benefit. A Unix domain socket cannot be reached from the network under any misconfiguration, which removes an entire class of accidental-exposure bugs. It also means Firecracker needs no authentication layer of its own: the socket file's filesystem permissions are the authorization model, and whoever can open that file can control the VM. In production the jailer reinforces this by chrooting the VMM, dropping to an unprivileged uid/gid, and placing the socket inside the jail so it's owned by that identity.
What does a 400 error from the Firecracker API usually mean?
Most often it means you called a pre-boot-only endpoint after `InstanceStart`, or a post-boot-only one before it. Firecracker's API is a state machine with one hard transition: `/machine-config`, `/boot-source`, and adding drives or network interfaces are configure-once operations that are frozen once the guest kernel is running, while `PATCH /vm` and the snapshot endpoints are only valid afterwards. The other two common causes are a missing required field and an unknown field — Firecracker rejects keys it doesn't recognize rather than ignoring them, so a typo in a key name is a hard failure. The response body includes a `fault_message` that usually states the real reason, so read it before bisecting your JSON.
How do I take and restore a Firecracker snapshot over the API?
Pause the running microVM first with `PATCH /vm` and `{"state": "Paused"}` — you cannot serialize vCPU state that's still changing. Then `PUT /snapshot/create` with `snapshot_type`, `snapshot_path`, and `mem_file_path` to write the VMM state and guest RAM to disk. To restore, start a brand-new Firecracker process that has never been started, and `PUT /snapshot/load` with `snapshot_path` and a `mem_backend` object containing `backend_path` and `backend_type` (`File`, or `Uffd` to serve memory faults from a userspace handler). You do not re-send boot-source, drives, or machine-config — they're inside the snapshot — but the host resources they referenced, such as the tap device and rootfs file, must exist again under the same names. If you loaded with `resume_vm: false`, unpause with `PATCH /vm` and `{"state": "Resumed"}`.
Should I use --config-file or the REST API to configure Firecracker?
Use `--config-file` for one-shot boots: local testing, kernel bring-up, and CI. You pass a single JSON document whose top-level keys mirror the API paths (`boot-source`, `drives`, `network-interfaces`, `machine-config`, `logger`), and Firecracker applies it and starts the microVM immediately with no `InstanceStart` call needed. The API socket still comes up for post-boot operations unless you also pass `--no-api`, which removes the control surface entirely — a reasonable hardening choice for a VM you'll start, run, and kill untouched. Use the REST API when you need runtime decisions: snapshots, forking, hot-swapping a drive, or anything where a supervising program is deciding what happens next. Note that in production you rarely cold-boot at all: the boot sequence runs once per template to produce a snapshot, and every create after that is `PUT /snapshot/load` plus a Resume.
49ms p50 cold start. Fork, snapshot, and scale to zero.