Getting Started
Nix for mvm: A Beginner's Guide
Learn just enough Nix to write your first mvm flake — no prior Nix experience required.
You don’t need to be a Nix expert to use mvm. This guide teaches you exactly what you need to know to write a flake that builds a microVM image. If you’ve never seen a .nix file before, start here.
What you’re building
Section titled “What you’re building”By the end of this guide, you’ll have a flake.nix file that tells mvm how to build a VM image containing your app. Running mvmctl machine build --flake . will produce a bootable microVM from that file.
Here’s the end result — don’t worry about understanding it yet:
{ inputs = { mvm.url = "github:tinylabscom/mvm?dir=nix"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; };
outputs = { mvm, nixpkgs, ... }: let system = "aarch64-linux"; pkgs = import nixpkgs { inherit system; }; in { packages.${system}.default = mvm.lib.${system}.mkGuest { name = "hello"; packages = [ pkgs.python3 pkgs.curl ];
entrypoint.command = [ "/bin/sh" "-c" "mkdir -p /tmp/www; echo '<h1>Hello from mvm!</h1>' > /tmp/www/index.html; exec ${pkgs.python3}/bin/python3 -m http.server 8080 --directory /tmp/www" ];
healthChecks.hello = { healthCmd = "${pkgs.curl}/bin/curl -sf http://localhost:8080/"; healthIntervalSecs = 5; }; }; };}Let’s build up to this step by step.
Step 1: Create the file
Section titled “Step 1: Create the file”Make a new directory for your project and create a flake.nix:
mkdir my-microvm && cd my-microvmtouch flake.nixEvery flake starts with the same skeleton — an attribute set with two keys, inputs and outputs:
{ inputs = { # where to get dependencies };
outputs = { ... }: { # what to build };}That’s really it. A flake is just a file that says “here’s where to get things” and “here’s what to make from them.”
Step 2: Declare inputs
Section titled “Step 2: Declare inputs”Inputs are the dependencies your flake needs — other Nix projects, package repositories, or libraries fetched from Git, GitHub, or local paths. Think of them like dependencies in a package.json or [dependencies] in a Cargo.toml, except Nix pins them to an exact revision automatically.
When you first build, Nix creates a flake.lock file that records the exact commit hash for each input. This means everyone who builds your flake — on any machine, at any time — gets the identical result. No “works on my machine” surprises.
For an mvm flake, you need two inputs:
inputs = { mvm.url = "github:tinylabscom/mvm?dir=nix"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";};Each input has a name (left of the =) and a URL (right side) that tells Nix where to fetch it:
mvm— the mvm guest library, fetched from GitHub. The?dir=nixpart tells Nix the flake lives in thenix/subdirectory of the repo. This providesmkGuest— the function that builds VM images. We’ll look at this in a minute.nixpkgs— the official Nix package repository, with 80,000+ packages (Python, Node, curl, PostgreSQL, and more). Thenixos-25.11branch gives you a stable, tested snapshot.
Step 3: Write the outputs function
Section titled “Step 3: Write the outputs function”The outputs key is a function. Nix passes your resolved inputs as arguments:
outputs = { mvm, nixpkgs, ... }: let system = "aarch64-linux"; pkgs = import nixpkgs { inherit system; }; in { # your packages go here };// Conceptual equivalent — not real codefunction outputs({ mvm, nixpkgs }) { const system = "aarch64-linux"; const pkgs = nixpkgs.import({ system });
return { packages: { [system]: { default: mvm.lib[system].mkGuest({ /* ... */ }) } } };}Let’s unpack what’s new here:
let ... in — defines local variables, like const in JavaScript. Everything between let and in is available in the expression after in.
system — the CPU architecture to build for. Use "aarch64-linux" for Apple Silicon Macs or ARM Linux, "x86_64-linux" for Intel/AMD. See the callout below for details.
pkgs — the full nixpkgs package set for your target system. After this line, pkgs.python3, pkgs.curl, pkgs.nodejs, etc. are all available.
inherit — a shorthand. { inherit system; } is the same as { system = system; } (or { system } in JavaScript).
Step 4: Define your VM image
Section titled “Step 4: Define your VM image”Inside the outputs, you tell Nix what to build. For mvm, that means calling mkGuest:
packages.${system}.default = mvm.lib.${system}.mkGuest { name = "hello"; entrypoint.command = [ "/bin/sh" "-c" "echo hello; exec sleep infinity" ];};mkGuest is the mvm function that builds a complete microVM image. It handles the kernel, init system, guest agent, networking, and drive mounting — you just describe what you want inside the VM.
entrypoint is required — there is no default. It must declare exactly one of shell, command, or services; mkGuest throws at evaluation time otherwise, so a flake that omits it never even reaches a build. command is the sealed (production) form and shell is the dev-tier form. services is declared but unimplemented — see the note in Step 6.
packages.${system}.default — this is the standard Nix way to declare a build output. mvmctl machine build --flake . looks for this by default.
Step 5: Add packages
Section titled “Step 5: Add packages”A bare VM isn’t very useful. Use packages to install software into the image:
mvm.lib.${system}.mkGuest { name = "hello"; packages = [ pkgs.python3 pkgs.curl ]; entrypoint.command = [ "/bin/sh" "-c" "echo hello; exec sleep infinity" ];};packages is a list of Nix packages to include in the rootfs. Anything in nixpkgs (80,000+ packages) is available via pkgs.<name>:
| What you want | Nix package name |
|---|---|
| Python 3 | pkgs.python3 |
| Node.js 22 | pkgs.nodejs_22 |
| curl | pkgs.curl |
| PostgreSQL 16 | pkgs.postgresql_16 |
| Redis | pkgs.redis |
| Go | pkgs.go |
| nginx | pkgs.nginx |
Search for packages at search.nixos.org/packages or from the command line:
# If you have Nix installed on your host (optional -- mvm's own build# path never needs it; see "host-side Nix for power users" in the# install guide)# Use 2>/dev/null to hide nixpkgs deprecation warningsnix search nixpkgs python 2>/dev/nullnix search nixpkgs "node.*22" 2>/dev/nullStep 6: Run something
Section titled “Step 6: Run something”A VM image with packages but no running process just boots and sits idle. The entrypoint is what runs:
mvm.lib.${system}.mkGuest { name = "hello"; packages = [ pkgs.python3 pkgs.curl ];
entrypoint.command = [ "/bin/sh" "-c" "mkdir -p /tmp/www; echo '<h1>Hello from mvm!</h1>' > /tmp/www/index.html; exec ${pkgs.python3}/bin/python3 -m http.server 8080 --directory /tmp/www" ];};entrypoint.command is an argv list that runs as PID 1. Do any setup first and
exec the long-running process last, so PID 1 stays alive — a PID 1 that exits
panics the kernel.
"${pkgs.python3}/bin/python3"— Nix resolves this to the full/nix/store/...path at build time. You never hardcode paths — Nix handles it.
Step 7: Add a health check
Section titled “Step 7: Add a health check”Health checks let mvm verify your service is actually working, not just running:
healthChecks.hello = { healthCmd = "${pkgs.curl}/bin/curl -sf http://localhost:8080/"; healthIntervalSecs = 5;};The guest agent runs this command every 5 seconds. Exit code 0 means healthy. The attribute name (hello) becomes the probe’s name — mkGuest renders each entry to /etc/mvm/probes.d/<name>.json, and the agent scans that directory at boot. Only healthCmd (required), healthIntervalSecs (default 30), and healthTimeoutSecs (default 10) are read; there is no startupGraceSecs key.
Step 8: Build, run, and verify
Section titled “Step 8: Build, run, and verify”You now have a complete flake. Build and boot it with port forwarding so you can access the service from your browser:
# Build the VM imagemvmctl machine build --flake .
# Boot a named machine with admitted host port 8080 → guest 8080mvmctl machine run --flake . --name hello --cpus 2 --memory 1024 --port 8080:8080--port records the exact mapping in the signed admission plan before the
FlowMux endpoint binds it. Without declared ingress, the service runs but is
not reachable from outside the VM.
Once it’s running, open your browser to http://localhost:8080. You should see:
Hello from mvm!
Your microVM is serving traffic from inside a Firecracker VM (or whichever backend mvm auto-selected for your platform).
# Check it's runningmvmctl machine ls
# View logs (including health check output)mvmctl machine logs hello -f
# To change ingress, restart with the complete declared mapping setmvmctl machine stop hellomvmctl machine run --flake . --name hello --port 8080:8080 --port 9090:9090
# Stop itmvmctl machine stop helloThe first build downloads dependencies and takes a few minutes. Subsequent builds with the same flake.lock can be much faster because Nix reuses cached inputs and build outputs.
Step 9: Mount volumes
Section titled “Step 9: Mount volumes”Volumes let you pass files from your host into the microVM at boot — config files, secrets, or persistent data:
# Mount host directories into the VMmvmctl machine run --flake . --name hello --port 8080:8080 \ --mount ./config:/data/config \ --mount ./disk.img:/data/store:1024:rwThe flag is --mount, with --volume as an alias. There is no short form —
-v is the global verbosity counter, not a mount flag.
Guest mount paths must live under /data or /work. Those are the only
allow-roots. /mnt is deliberately excluded: /init mounts mvm’s own read-only
config and secret drives at /mnt/config and /mnt/secrets before any user
volume is attached, and the mount policy refuses a share that would sit inside
or shadow a runtime-owned path.
Inside the guest, the files appear at the mount paths:
| Mount | Access | Kind |
|---|---|---|
./config:/data/config | Read-only (default) | Live host-directory share |
./config:/data/config:rw | Read-write | Live host-directory share |
./disk.img:/data/store:1024 | Read-only (default) | Persistent ext4 disk image |
./disk.img:/data/store:1024:rw | Read-write | Persistent ext4 disk image |
Every mount is read-only unless you write :rw — writing from a
(potentially untrusted) guest to a host path is an explicit grant, not a
default. :rw needs --profile dev or --profile permissive; a transient
run’s share is read-only under every profile.
The three-part form host:guest:SIZE selects an empty disk image: the host
path is a file mvm creates and attaches as virtio-blk, sized in MB. Add :rw
after the size for a writable disk.
Two-part host:guest shares the contents of an existing host directory. mvm
copies the directory into an ext4 image at launch and attaches that, so the
guest sees a point-in-time snapshot rather than a live view: edits you make
on the host while the VM is running are not visible inside it, and the guest
never writes back. Re-run to pick up changes.
This replaced a live virtio-fs share, and the reason is the security model — virtio-fs put a FUSE server on the host parsing requests an untrusted guest composed, which was the one mechanism by which a guest addressed host filesystem structure rather than opaque blocks. A disk image has no protocol for a guest to drive. The snapshot is also read-only under every profile for a transient run, so mid-run visibility is the only thing that changed.
There is no size limit: the walk records each file’s path and size and the
bytes are streamed into the image as it is written, so peak memory is a block
per file rather than the whole tree. Mounting a directory does still copy it,
and .gitignore is deliberately not consulted — a user who mounts a directory
means the directory — so mounting a large build tree costs the time and disk to
write it.
Your entrypoint can read config and write data at runtime — no rebuild needed:
entrypoint.command = [ "/bin/sh" "-c" '' mkdir -p /data/store/logs export CONFIG_PATH=/data/config/app.json export LOG_DIR=/data/store/logs exec ${pkgs.python3}/bin/python3 /app/server.py ''];This is the mvm pattern for separating what runs (baked into the image) from how it’s configured (mounted at boot). The same image works across environments — only the mounted files change. Disk volumes persist across restarts.
See Config & Secrets and Filesystem & Drives for the full guide.
Step 10: Add environment variables and setup steps
Section titled “Step 10: Add environment variables and setup steps”Real workloads often need configuration. Because entrypoint.command is a plain
argv list, set it up in the shell you exec into:
entrypoint.command = [ "/bin/sh" "-c" '' mkdir -p /tmp/data echo "starting up..."
export PORT=8080 export LOG_LEVEL=info
exec ${pkgs.python3}/bin/python3 /app/server.py ''];- setup first,
execlast — anything before theexecruns once as root at boot; theexecreplaces the shell so your program becomes PID 1. ''...''— multi-line strings in Nix use two single quotes, not backticks or double quotes.
(The env and preStart keys you may have seen belong to the services shape,
which is unimplemented — see the note in Step 6. They are silently ignored.)
Common recipes
Section titled “Common recipes”Python web app
Section titled “Python web app”entrypoint.command = [ "${pkgs.python3}/bin/python3" "-m" "gunicorn" "app:app" "-b" "0.0.0.0:8080"];Node.js server
Section titled “Node.js server”entrypoint.command = [ "/bin/sh" "-c" '' export NODE_ENV=production PORT=3000 exec ${pkgs.nodejs}/bin/node /app/server.js ''];Static file server
Section titled “Static file server”entrypoint.command = [ "/bin/sh" "-c" '' mkdir -p /tmp/www echo '<h1>Hello!</h1>' > /tmp/www/index.html exec ${pkgs.python3}/bin/python3 -m http.server 8080 --directory /tmp/www ''];Multiple processes in one VM
Section titled “Multiple processes in one VM”There is no supervisor yet, so a services block will not run anything (see the
note in Step 6). If you genuinely need a co-located dependency today, start it
from the entrypoint yourself and keep the foreground process as PID 1:
mvm.lib.${system}.mkGuest { name = "my-stack"; packages = [ pkgs.python3 pkgs.redis pkgs.curl ];
entrypoint.command = [ "/bin/sh" "-c" "${pkgs.redis}/bin/redis-server --port 6379 --daemonize yes; export REDIS_URL=redis://localhost:6379; exec ${pkgs.python3}/bin/python3 /app/server.py" ];
healthChecks.api = { healthCmd = "${pkgs.curl}/bin/curl -sf http://localhost:8080/health"; healthIntervalSecs = 10; };};Nothing restarts the backgrounded process if it dies — that is exactly what the missing supervisor would provide. Prefer one workload per microVM until it lands.
What happens when you build
Section titled “What happens when you build”When mvmctl machine build --flake . runs:
- Nix reads your
flake.nixand resolves all inputs - It builds your packages and services into a root filesystem (ext4 image)
- It bundles a pre-built slim Linux kernel for the selected backend
- The result is cached — rebuilds skip unchanged steps
The output is a kernel (vmlinux) and rootfs (rootfs.ext4) that any mvm backend can boot.
Troubleshooting
Section titled “Troubleshooting””error: getting status of ‘/nix/store/…’: No such file or directory”
Section titled “”error: getting status of ‘/nix/store/…’: No such file or directory””Your flake references a path that doesn’t exist. Check your src paths.
”error: attribute ‘xyz’ missing”
Section titled “”error: attribute ‘xyz’ missing””A package name is wrong. Search for it at search.nixos.org/packages.
”infinite recursion encountered”
Section titled “”infinite recursion encountered””Usually caused by circular references in let bindings. Check for variables that reference each other.
Slow first build
Section titled “Slow first build”Normal — Nix is downloading and caching all dependencies. Subsequent builds will be fast. Run mvmctl env cleanup periodically to free old cached builds.
Next steps
Section titled “Next steps”- Writing Nix Flakes — the full mkGuest API reference, service helpers, profiles
- Manifests — pair your flake with an
mvm.tomlformvmctl init/build/up - Config & Secrets — inject config files and API keys at boot
Appendix: Nix syntax cheat sheet
Section titled “Appendix: Nix syntax cheat sheet”If you’re curious about the Nix language itself, here’s a quick reference for everything used in mvm flakes.
Values
Section titled “Values”"hello" # string'' multi-line string'' # multi-line string (two single quotes)42 # integertrue # booleannull # null[ 1 2 3 ] # list (space-separated, no commas){ a = 1; b = 2; } # attribute set (like an object/dict)String interpolation
Section titled “String interpolation”"Hello ${name}" # inserts the value of 'name'"${pkgs.curl}/bin/curl" # resolves to the Nix store path of curllet bindings
Section titled “let bindings”let port = 8080; host = "0.0.0.0";in "${host}:${toString port}" # "0.0.0.0:8080"Like const in JavaScript. Variables defined between let and in are available in the expression after in.
Functions
Section titled “Functions”# Single argumentgreet = name: "Hello ${name}";greet "world" # "Hello world"
# Multiple arguments (curried)add = a: b: a + b;add 1 2 # 3
# Attribute set argument (destructuring)f = { name, port }: "${name}:${toString port}";f { name = "app"; port = 8080; } # "app:8080"inherit
Section titled “inherit”let system = "aarch64-linux";in { inherit system; }# same as: { system = system; }Shorthand for “copy this variable into the attribute set with the same name.”
Attribute access
Section titled “Attribute access”pkgs.python3 # dot notationpkgs.${system} # dynamic key (interpolation)Key Nix concepts
Section titled “Key Nix concepts”| Concept | What it means |
|---|---|
| Store path | Every package lives in /nix/store/hash-name/. Paths are content-addressed — same input always produces the same path. |
| Derivation | A build recipe. mkGuest, mkDerivation, etc. are all functions that return derivations. |
| Flake | A flake.nix file with pinned dependencies (flake.lock). Guarantees reproducibility. |
| Closure | A package plus all its dependencies. When Nix builds your rootfs, it includes the full closure of every package you listed. |