Nix for mvm: A Beginner's Guide
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 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:auser/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 ];
services.hello = { preStart = '' mkdir -p /tmp/www echo '<h1>Hello from mvm!</h1>' > /tmp/www/index.html ''; command = "${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:auser/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";};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.
packages.${system}.default — this is the standard Nix way to declare a build output. mvmctl 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 ];};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:
# Inside the dev shell (mvmctl dev)# Use 2>/dev/null to hide nixpkgs deprecation warningsnix search nixpkgs python 2>/dev/nullnix search nixpkgs "node.*22" 2>/dev/nullStep 6: Add a service
Section titled “Step 6: Add a service”A VM image with packages but no running process just boots and sits idle. Add a service:
mvm.lib.${system}.mkGuest { name = "hello"; packages = [ pkgs.python3 pkgs.curl ];
services.hello = { preStart = '' mkdir -p /tmp/www echo '<h1>Hello from mvm!</h1>' > /tmp/www/index.html ''; command = "${pkgs.python3}/bin/python3 -m http.server 8080 --directory /tmp/www"; };};services.hello declares a long-running process. mvm’s init system starts it at boot and restarts it if it crashes.
preStartcreates a directory and writes an HTML file before the service starts.commandis the long-running process. It serves the HTML on port 8080."${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 name (hello) should match your service name.
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 build --flake .
# Boot with port forwarding — map host port 8080 to guest port 8080mvmctl up --flake . --cpus 2 --memory 1024 -p 8080:8080The -p 8080:8080 flag forwards port 8080 on your host to port 8080 inside the microVM. Without it, the service runs but isn’t accessible 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 ls
# View logs (including health check output)mvmctl logs hello -f
# Forward additional ports from a running VMmvmctl forward hello -p 9090:9090
# Stop itmvmctl down helloThe first build downloads dependencies and takes a few minutes. Subsequent builds with the same flake.lock are near-instant thanks to Nix caching.
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 up --flake . -p 8080:8080 \ -v ./config:/mnt/config \ -v ./secrets:/mnt/secrets \ -v ./data:/mnt/data:1024Inside the guest, the files appear at the mount paths:
| Mount | Access | Use case |
|---|---|---|
./config:/mnt/config | Read-only | App configuration |
./secrets:/mnt/secrets | Read-only | API keys, tokens |
./data:/mnt/data:1024 | Read-write | Database files, logs, uploads |
Config and secrets are read-only by design — you don’t want your app accidentally overwriting its own API keys. For data your service needs to write to (databases, logs, user uploads), use a sized volume with the three-part format host:guest:sizeMB:
# Read-only config + read-write 1GB data volumemvmctl up --flake . -p 8080:8080 \ -v ./config:/mnt/config \ -v ./data:/mnt/data:1024Your service can read config and write data at runtime — no rebuild needed:
services.my-api = { preStart = '' # Data dir is read-write — create subdirs as needed mkdir -p /mnt/data/logs ''; command = "${pkgs.python3}/bin/python3 /app/server.py"; env = { # Read config directly from the mounted volume CONFIG_PATH = "/mnt/config/app.json"; LOG_DIR = "/mnt/data/logs"; };};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. Data volumes persist across restarts.
See Config & Secrets and Filesystem & Drives for the full guide.
Step 10: Add environment variables and setup scripts
Section titled “Step 10: Add environment variables and setup scripts”Real services often need configuration. Use env and preStart:
services.my-api = { preStart = '' mkdir -p /tmp/data echo "starting up..." '';
command = "${pkgs.python3}/bin/python3 /app/server.py";
env = { PORT = "8080"; LOG_LEVEL = "info"; };};env— key-value pairs injected as environment variables into the service process.preStart— runs once as root before the service starts. Use it for directory setup, file permissions, etc.''...''— multi-line strings in Nix use two single quotes, not backticks or double quotes.
Common recipes
Section titled “Common recipes”Python web app
Section titled “Python web app”services.api = { command = "${pkgs.python3}/bin/python3 -m gunicorn app:app -b 0.0.0.0:8080";};Node.js server
Section titled “Node.js server”services.app = { command = "${pkgs.nodejs}/bin/node /app/server.js"; env = { NODE_ENV = "production"; PORT = "3000"; };};Static file server
Section titled “Static file server”services.www = { preStart = '' mkdir -p /tmp/www echo '<h1>Hello!</h1>' > /tmp/www/index.html ''; command = "${pkgs.python3}/bin/python3 -m http.server 8080 --directory /tmp/www";};Multiple services in one VM
Section titled “Multiple services in one VM”A single mkGuest call can define multiple services — they all run inside the same VM, supervised by the init system. This is useful for co-locating a service with its dependencies (e.g., an API server + Redis):
mvm.lib.${system}.mkGuest { name = "my-stack"; packages = [ pkgs.python3 pkgs.redis pkgs.curl ];
services.redis = { command = "${pkgs.redis}/bin/redis-server --port 6379"; };
services.api = { command = "${pkgs.python3}/bin/python3 /app/server.py"; env = { REDIS_URL = "redis://localhost:6379"; }; };
healthChecks.api = { healthCmd = "${pkgs.curl}/bin/curl -sf http://localhost:8080/health"; healthIntervalSecs = 10; };};What happens when you build
Section titled “What happens when you build”When mvmctl 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 Linux kernel tuned for Firecracker
- 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 cleanup periodically to free old cached builds.
Next steps
Section titled “Next steps”- Writing Nix Flakes — the full mkGuest API reference, service helpers, profiles
- Templates — save your image as a reusable template
- 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. |