Skip to content

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.

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.

Make a new directory for your project and create a flake.nix:

Terminal window
mkdir my-microvm && cd my-microvm
touch flake.nix

Every 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.”

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=nix part tells Nix the flake lives in the nix/ subdirectory of the repo. This provides mkGuest — 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). The nixos-25.11 branch gives you a stable, tested snapshot.

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
};

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).

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.

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 wantNix package name
Python 3pkgs.python3
Node.js 22pkgs.nodejs_22
curlpkgs.curl
PostgreSQL 16pkgs.postgresql_16
Redispkgs.redis
Gopkgs.go
nginxpkgs.nginx

Search for packages at search.nixos.org/packages or from the command line:

Terminal window
# Inside the dev shell (mvmctl dev)
# Use 2>/dev/null to hide nixpkgs deprecation warnings
nix search nixpkgs python 2>/dev/null
nix search nixpkgs "node.*22" 2>/dev/null

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.

  • preStart creates a directory and writes an HTML file before the service starts.
  • command is 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.

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.

You now have a complete flake. Build and boot it with port forwarding so you can access the service from your browser:

Terminal window
# Build the VM image
mvmctl build --flake .
# Boot with port forwarding — map host port 8080 to guest port 8080
mvmctl up --flake . --cpus 2 --memory 1024 -p 8080:8080

The -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).

Terminal window
# Check it's running
mvmctl ls
# View logs (including health check output)
mvmctl logs hello -f
# Forward additional ports from a running VM
mvmctl forward hello -p 9090:9090
# Stop it
mvmctl down hello

The first build downloads dependencies and takes a few minutes. Subsequent builds with the same flake.lock are near-instant thanks to Nix caching.

Volumes let you pass files from your host into the microVM at boot — config files, secrets, or persistent data:

Terminal window
# Mount host directories into the VM
mvmctl up --flake . -p 8080:8080 \
-v ./config:/mnt/config \
-v ./secrets:/mnt/secrets \
-v ./data:/mnt/data:1024

Inside the guest, the files appear at the mount paths:

MountAccessUse case
./config:/mnt/configRead-onlyApp configuration
./secrets:/mnt/secretsRead-onlyAPI keys, tokens
./data:/mnt/data:1024Read-writeDatabase 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:

Terminal window
# Read-only config + read-write 1GB data volume
mvmctl up --flake . -p 8080:8080 \
-v ./config:/mnt/config \
-v ./data:/mnt/data:1024

Your 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.
services.api = {
command = "${pkgs.python3}/bin/python3 -m gunicorn app:app -b 0.0.0.0:8080";
};
services.app = {
command = "${pkgs.nodejs}/bin/node /app/server.js";
env = { NODE_ENV = "production"; PORT = "3000"; };
};
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";
};

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;
};
};

When mvmctl build --flake . runs:

  1. Nix reads your flake.nix and resolves all inputs
  2. It builds your packages and services into a root filesystem (ext4 image)
  3. It bundles a pre-built Linux kernel tuned for Firecracker
  4. The result is cached — rebuilds skip unchanged steps

The output is a kernel (vmlinux) and rootfs (rootfs.ext4) that any mvm backend can boot.

”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.

A package name is wrong. Search for it at search.nixos.org/packages.

Usually caused by circular references in let bindings. Check for variables that reference each other.

Normal — Nix is downloading and caching all dependencies. Subsequent builds will be fast. Run mvmctl cleanup periodically to free old cached builds.


If you’re curious about the Nix language itself, here’s a quick reference for everything used in mvm flakes.

"hello" # string
''
multi-line
string
'' # multi-line string (two single quotes)
42 # integer
true # boolean
null # null
[ 1 2 3 ] # list (space-separated, no commas)
{ a = 1; b = 2; } # attribute set (like an object/dict)
"Hello ${name}" # inserts the value of 'name'
"${pkgs.curl}/bin/curl" # resolves to the Nix store path of curl
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.

# Single argument
greet = 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"
let system = "aarch64-linux";
in { inherit system; }
# same as: { system = system; }

Shorthand for “copy this variable into the attribute set with the same name.”

pkgs.python3 # dot notation
pkgs.${system} # dynamic key (interpolation)
ConceptWhat it means
Store pathEvery package lives in /nix/store/hash-name/. Paths are content-addressed — same input always produces the same path.
DerivationA build recipe. mkGuest, mkDerivation, etc. are all functions that return derivations.
FlakeA flake.nix file with pinned dependencies (flake.lock). Guarantees reproducibility.
ClosureA package plus all its dependencies. When Nix builds your rootfs, it includes the full closure of every package you listed.