···11+---
22+atroot: true
33+template:
44+slug: spindle-microvm
55+title: Spindle's new microVM engine
66+subtitle: How we built the new QEMU-based microVM engine
77+date: 2026-06-16
88+image: https://assets.tangled.network/blog/microvm.png
99+authors:
1010+ - name: dawn
1111+ email: dawn@tangled.org
1212+ handle: ptr.pet
1313+---
1414+1515+Spindle gains a second engine: `microvm`. Each workflow gets its own little
1616+virtual machine, a whole real environment you can do anything inside. It's an
1717+upgrade from the Nixery engine while staying fully compatible with it, so if you
1818+already have a working Nixery workflow, just change `nixery` to `microvm` and it
1919+will work!
2020+2121+The interesting part is NixOS images: you configure the machine directly from
2222+the workflow file. A few things you can do:
2323+2424+You can bring services up:
2525+2626+```yaml
2727+services:
2828+ postgresql:
2929+ enable: true
3030+ ensureDatabases: ["spindle-workflow"]
3131+ ensureUsers:
3232+ - name: spindle-workflow
3333+ ensureDBOwnership: true
3434+```
3535+3636+You can build Docker containers:
3737+3838+```yaml
3939+virtualisation:
4040+ docker: true
4141+steps:
4242+ - name: "do the thing!"
4343+ command: docker build ...
4444+```
4545+4646+And you can use non-NixOS images too:
4747+4848+```yaml
4949+image: alpine
5050+steps:
5151+ - name: install golang
5252+ command: apk add go
5353+```
5454+5555+It's quick on the second run, too, because it caches aggressively: your
5656+dependencies, your services, and any other Nix derivation built inside the
5757+microVM get pushed to spindle's Nix cache, so the next workflow that needs them
5858+doesn't rebuild those. More on that [below](#the-nix-cache-both-ways).
5959+6060+And like everything else in tangled, the whole thing is self-hostable, so you
6161+can run your own spindle with the microVM engine on your own hardware (see the
6262+[self-hosting
6363+guide](https://docs.tangled.org/spindles.html#self-hosting-guide)). If you want
6464+fuller examples, there are [recipes in the
6565+docs](https://docs.tangled.org/spindles.html#recipes) too.
6666+6767+## What's in a microVM
6868+6969+A microVM is just a VM with most of the boring parts removed. There's no BIOS,
7070+no PCI bus to probe, no emulated graphics card, none of the slow legacy stuff a
7171+normal QEMU machine drags along. You get virtio devices and not much
7272+else, which means it boots very quickly and uses very little memory. Right now
7373+QEMU is the only runner we support, but the engine is written so that other
7474+runners (firecracker for example) can slot in later.
7575+7676+Inside the guest there's a small piece of software we call the agent. Spindle
7777+never SSHes in or runs commands "from the outside"; instead the agent dials back
7878+to spindle over vsock the moment it boots, says hello, and from then on every
7979+step of your workflow is sent to it as a message. The agent runs the command as
8080+an unprivileged user, streams stdout and stderr back, and reports the exit code.
8181+The host side of this lives in
8282+[`spindle`](https://tangled.org/tangled.org/core/tree/master/spindle/engines/microvm/agent.go)
8383+and the guest side is a little Rust binary called
8484+[`shuttle`](https://tangled.org/tangled.org/core/tree/master/shuttle).
8585+(`shuttle` implements
8686+[`agentproto`](https://tangled.org/tangled.org/core/tree/master/spindle/) which
8787+is the protocol used by `spindle`. Technically speaking anyone could implement
8888+this and, assuming side effects hold, you could have your own agent!)
8989+9090+
9191+9292+## Two kinds of images
9393+9494+There are two "flavours" of image you can boot, and they're aimed at fairly
9595+different people.
9696+9797+The first is **NixOS images**. These are the interesting ones: because the whole
9898+guest is built with Nix, you can configure it from your workflow file directly.
9999+Things like `dependencies`, `services`, `virtualisation` (e.g. Docker),
100100+`registry` and `caches` are all written right there in the YAML, and the guest
101101+agent builds and activates that config before any of your steps run. If we've
102102+built that exact base plus config before, spindle can just hand the guest a
103103+store path to realize (fetching from whatever cache `spindle` has configured)
104104+instead of rebuilding it, so the second run is quick.
105105+106106+The second is **non-NixOS images**, which today just means Alpine, but can be
107107+anything. You don't get the workflow-level NixOS config here (there's no NixOS
108108+to configure), but if Nix happens to exist inside the image, like it does in our
109109+Alpine one, it can still talk to the spindle Nix cache just fine.
110110+111111+## An example NixOS workflow
112112+113113+If you've used spindle before, this will look familiar: it's the same manifest
114114+you already know, just with a few extra keys that the NixOS image understands.
115115+Here's a workflow that needs Postgres to test against and Docker to build an
116116+image:
117117+118118+```yaml
119119+# .tangled/workflows/test.yaml
120120+engine: microvm
121121+122122+when:
123123+ - event: ["push", "pull_request"]
124124+ branch: ["master"]
125125+126126+image: nixos
127127+128128+dependencies:
129129+ - go
130130+ - github:nixos/nixpkgs#hello
131131+132132+registry:
133133+ nixpkgs: github:nixos/nixpkgs/nixos-unstable
134134+135135+caches:
136136+ https://nix-community.cachix.org: "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
137137+138138+services:
139139+ postgresql:
140140+ enable: true
141141+ ensureDatabases: ["spindle-workflow"]
142142+ ensureUsers:
143143+ - name: spindle-workflow
144144+ ensureDBOwnership: true
145145+146146+virtualisation:
147147+ docker: true
148148+149149+steps:
150150+ - name: run tests
151151+ environment:
152152+ PGHOST: /run/postgresql
153153+ command: |
154154+ docker build -t app .
155155+ psql -c "select 1"
156156+ go test ./...
157157+```
158158+159159+The new keys each do one job:
160160+161161+- **`dependencies`** are the packages your steps get to use. They go into a
162162+ `mkShellNoCC` devshell that every step sources before it runs, so you get the
163163+ whole stdenv environment (setup hooks like `pkg-config` wiring up
164164+ `PKG_CONFIG_PATH`, etc.) and not just the bare binaries. That means you can
165165+ use a dependency like `openssl` and compile the `openssl-sys` Rust crate
166166+ without pain! A bare name like `go` is looked up in nixpkgs (same as Nixery),
167167+ but you can also point at any flake with the `flakeref#attr` syntax, so
168168+ `github:nixos/nixpkgs#hello` pulls `hello` straight out of that flake.
169169+- **`registry`** is how you remap the global refs. Here we pin `nixpkgs` to
170170+ `nixos-unstable`, so now the bare `go` above resolves from unstable. You can
171171+ alias your own flakes the same way (`myflake: github:me/x`, then
172172+ `myflake#tool` in `dependencies`).
173173+- **`caches`** is a map of binary cache URL to its trusted public key. They get
174174+ wired into the read proxy (more on that just below), so the guest can
175175+ substitute prebuilt paths from them instead of building everything from
176176+ scratch.
177177+178178+`services` and `virtualisation` are the interesting parts: they're passed
179179+straight through to NixOS, so anything you could write in a NixOS config you can
180180+write here. `services.postgresql.enable` brings Postgres up before any of your
181181+steps run.
182182+183183+Since steps run as the `spindle-workflow` user, naming a database after that
184184+user with `ensureDBOwnership` is the easy path to a working DB -- Postgres peer
185185+auth maps the unix user straight to the matching role, so `psql` connects over
186186+the socket with no password and no extra setup (this name-matching is a NixOS
187187+requirement for `ensureDBOwnership`, if you want a differently named DB you'd
188188+grant access yourself).
189189+190190+`virtualisation.docker: true` is shorthand for `virtualisation.docker.enable =
191191+true`, which gets you a real Docker daemon inside the VM. By the time your first
192192+step runs, Postgres is listening and the Docker socket is there, no sidecar
193193+dance, it's just part of the machine.
194194+195195+(`true` works as shorthand for `.enable = true` anywhere an `enable` option
196196+exists, so most "just turn this on" services are a one-liner!)
197197+198198+## The architecture
199199+200200+
201201+202202+### Nix cache, both ways
203203+204204+Spindle talks to its Nix cache through two proxies that run on the host, so the
205205+guest never needs credentials or direct network access to reach it. Like the
206206+agent, they use vsock to talk to spindle.
207207+208208+The read proxy fans out to the configured substituters plus any caches you
209209+listed in your workflow, so when the guest needs to realize a store path it asks
210210+the proxy and the proxy fetches it. The request is sent concurrently to the read
211211+caches, so the one that answers it first wins.
212212+213213+The upload proxy goes the other way: any path built inside the guest gets pushed
214214+back out to spindle's Nix cache (if one is configured), so the next workflow
215215+that needs it doesn't have to build it again. Any paths that already exist on
216216+any of the configured read caches won't be uploaded. As the agent reports
217217+built paths, they're queued and uploaded in the background while the rest of the
218218+workflow keeps running, so uploads overlap with work instead of blocking it. If
219219+any are still in flight when we reach VM teardown, the workflow waits until
220220+everything has drained.
221221+222222+Spindle can be configured to use `http`, `ssh-ng` or `ssh` URLs as a binary
223223+cache to upload to, so for example, `ssh-ng://localhost` would just upload to
224224+the local Nix store on the machine that the spindle runs on! `ssh-ng` and `ssh`
225225+require Nix to be present in PATH so that the spindle can use `nix copy` to
226226+upload to them, but if you are using a binary cache that supports `http` (for
227227+example, [ncps](https://github.com/kalbasit/ncps)) Nix does not need to be
228228+present.
229229+230230+### Building the images
231231+232232+Image builds are done with Nix. For NixOS we lean on
233233+[microvm.nix](https://github.com/microvm-nix/microvm.nix) and layer our own bits
234234+on top (stripping down kernel modules, configuring users, etc.). For Alpine
235235+there's a smallish Nix definition that fetches the kernel, the initrd and the
236236+kernel modules, sets up an init script that configures the machine on boot,
237237+copies in the dependencies we want (`nix`, `git`, etc.) and compresses the whole
238238+rootfs into a squashfs.
239239+240240+None of this *has* to be Nix, though. As far as spindle is concerned an image is
241241+valid as long as a few things hold: a guest agent (that implements `agentproto`)
242242+is present and gets started on boot, a `spindle-workflow` user exists, and the
243243+work directory is set up at `/workspace`. That can be built however you like.
244244+245245+### Finding an image
246246+247247+Every built image ships a `spec.json` next to its artifacts. The spec is the
248248+whole contract: where the kernel and initrd and read-only store disk live, the
249249+boot args, how much memory and how many vCPUs to give it, the shell to run steps
250250+in, the writable volumes, the network interfaces, and the runner-specific knobs
251251+(machine type, CPU, extra QEMU args). NixOS images also carry a `baseConfigHash`
252252+identifying the base config baked in (this is the hash of
253253+`nixosSystem.config.system.build.toplevel.outPath`).
254254+255255+A workflow picks an image with the `image` key at the top level. The name is
256256+matched literally against what's on disk, we look for a directory called
257257+`<name>` with a `spec.json` in it, then fall back to a flat `<name>.json`. The
258258+nice property here is that resolution depends *only* on the name and what's on
259259+disk, never on the host doing the resolving, so the same workflow resolves to
260260+the same image on every spindle. If an operator keeps multiple arches side by
261261+side they can name them `nixos-x86_64`, `alpine-aarch64` and so on (that suffix
262262+is just part of the name, it's not handled specially). If you want, for example,
263263+`nixos` to work, you can just symlink `nixos` to `nixos-x86_64`.
264264+265265+Right before launch we double-check the referenced files actually exist
266266+and that the host has the tools we need: `mkfs.ext4` for the volumes, the
267267+QEMU binary for the spec's arch, `/dev/kvm` and `/dev/vhost-vsock`, plus
268268+the `ip` / `mount` / `slirp4netns` / `unshare` toolchain if the image
269269+wants networking.
270270+271271+### The life of a workflow
272272+273273+A workflow moves through a handful of stages: it gets parsed and its
274274+image resolved, it waits for a slot, it gets set up, its steps run, and
275275+then everything is torn down.
276276+277277+The waiting bit matters a lot. Each image declares how much memory, how many
278278+vCPUs and how much disk it needs, and a workflow has to acquire a slot from a
279279+resource scheduler before anything boots. The scheduler is work-conserving with
280280+aging and per-user fairness, so one person submitting a hundred jobs won't
281281+starve everyone else, and slots don't sit idle if there's work that fits in the
282282+budget.
283283+284284+Once a slot is acquired, we do the setup. Spindle allocates a random vsock CID
285285+for the guest and registers it with the agent hub. It creates the per-workflow
286286+work directory, starts the two cache proxies (described earlier), a DNS proxy
287287+that resolves through the host and filters out private/special-use addresses,
288288+then creates the VM: writable volumes become sparse files formatted ext4, the
289289+store disk is attached read-only, and QEMU is started with `-sandbox on`,
290290+`-nodefaults`, no display, no monitor, etc. with serial (on boot) /
291291+`virtio_console` output to a log file and a QMP socket for control.
292292+293293+Then we wait for the machine. We poll QMP until QEMU says the guest is running,
294294+then wait for the agent's handshake to arrive over vsock from the CID we expect.
295295+The agent tells us its protocol and versions, and spindle sends back the job id,
296296+the trusted cache public keys, and the cache and DNS proxy ports. From there
297297+steps run one at a time as `$shell -lc <command>`, as the unprivileged workflow
298298+user in `/workspace/repo`, with the right environment and any unlocked secrets.
299299+If the workflow activates a NixOS config and we've already built that exact base
300300+plus config, the activation step can realize a cached toplevel store path instead
301301+of rebuilding. Either way, whether it's building the config fresh or pulling a
302302+cached toplevel down, that output streams straight into the activation step's log
303303+as it happens, so you can watch the closure come in instead of staring at a blank
304304+screen wondering if anything's happening.
305305+306306+Timeouts are cooperative: we work out a deadline from the workflow timeout
307307+and send it to the guest, with a little grace on our side so the guest
308308+gets a chance to report the timeout itself rather than us just yanking the
309309+machine out from under it. And if the VM crashes mid-step we tail the
310310+serial and QEMU logs into the step's stderr, because "guest agent
311311+connection lost: EOF" is a genuinely useless thing to read at 2am...
312312+313313+Teardown is the same whether the workflow passed, failed or timed out:
314314+drain any pending Nix cache uploads, ask the agent to power off, wait for
315315+QEMU to exit (falling back to a QMP `system_powerdown`, and finally a
316316+kill if it's being stubborn), then close the proxies and remove the work
317317+directory.
318318+319319+### Locking down the network
320320+321321+A VM that can reach the host's local network is a VM that can reach things it
322322+has no business reaching. So QEMU doesn't run in the host's network namespace at
323323+all. We `unshare` into fresh user, net and mount namespaces first. Inside that
324324+namespace a small wrapper bind-mounts a resolv.conf pointing at `127.0.0.1` so
325325+that QEMU's built-in slirp DNS isn't used, then installs blackhole routes for
326326+every special-use IP range (RFC 6890, so private networks, link-local, loopback,
327327+etc.) before it execs QEMU. `slirp4netns` then provides the namespace's outbound
328328+internet connection, with `--disable-host-loopback`, sandbox and seccomp all on.
329329+QEMU runs *inside* that namespace, and the guest's network card is attached to
330330+QEMU's own built-in user-mode networking. So every packet from the guest takes
331331+two hops: guest → QEMU's slirp → the namespace's `slirp4netns` → the internet.
332332+The guest never sees the host's network and the host's network never sees the
333333+guest. All of this is done without needing any privileges!
334334+335335+Guest DNS doesn't use either slirp layer. The guest's `/etc/resolv.conf` points
336336+at shuttle on `127.0.0.1:53`, and shuttle forwards DNS packets over vsock to
337337+the host-side DNS proxy. That proxy resolves through the host's real resolver
338338+and strips any answers that point at private or special-use addresses, so guest
339339+traffic can only ever reach the outside world, never the host or anything on its
340340+local networks.
341341+342342+### Budgets and cgroups
343343+344344+The scheduler's budget is bookkeeping on its own, it tracks what it's handed
345345+out, and the runner (QEMU) will ensure that a workflow only gets those. But
346346+optionally the whole thing (QEMU and slirp4netns both) gets placed in a
347347+per-workflow cgroup with memory, swap etc. limits, which is an extra enforcement
348348+layer on top, considering QEMU and slirp4netns themselves also use resources. A
349349+nice side effect is that when the cgroup OOM-kills the VM we can see that it was
350350+an OOM and report it as such, instead of surfacing it as a generic crash and
351351+leaving you guessing.
352352+353353+The spindle itself also gets a cgroup with `memory.min` set, which means that in
354354+a host OOM situation, it should be the workflows that die first, not the spindle
355355+itself.
356356+357357+## On the roadmap
358358+359359+A few things that are coming next:
360360+361361+- [firecracker](https://github.com/firecracker-microvm/firecracker) runner
362362+ support. QEMU microVMs are good and all, but firecracker VMs are more
363363+ efficient to run concurrently and are leaner overall.
364364+- ssh-on-fail: when a workflow fails, you should be able to ssh in to debug why.
365365+ This can be really useful in situations where you need just *a little* bit
366366+ more info if something unexpected fails so you don't sit around there running
367367+ the workflow 10 times over.
368368+369369+Feel free to come and ask any questions you might have on https://chat.tangled.sh!