Skip to content

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.

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.

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: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=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";
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.

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

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.

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 machine build --flake .
# Boot a named machine with admitted host port 8080 → guest 8080
mvmctl 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).

Terminal window
# Check it's running
mvmctl machine ls
# View logs (including health check output)
mvmctl machine logs hello -f
# To change ingress, restart with the complete declared mapping set
mvmctl machine stop hello
mvmctl machine run --flake . --name hello --port 8080:8080 --port 9090:9090
# Stop it
mvmctl machine stop hello

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

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 machine run --flake . --name hello --port 8080:8080 \
--mount ./config:/data/config \
--mount ./disk.img:/data/store:1024:rw

The 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:

MountAccessKind
./config:/data/configRead-only (default)Live host-directory share
./config:/data/config:rwRead-writeLive host-directory share
./disk.img:/data/store:1024Read-only (default)Persistent ext4 disk image
./disk.img:/data/store:1024:rwRead-writePersistent 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, exec last — anything before the exec runs once as root at boot; the exec replaces 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.)

entrypoint.command = [
"${pkgs.python3}/bin/python3" "-m" "gunicorn" "app:app" "-b" "0.0.0.0:8080"
];
entrypoint.command = [
"/bin/sh" "-c"
''
export NODE_ENV=production PORT=3000
exec ${pkgs.nodejs}/bin/node /app/server.js
''
];
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
''
];

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.

When mvmctl machine 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 slim Linux kernel for the selected backend
  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 env cleanup periodically to free old cached builds.

  • Writing Nix Flakes — the full mkGuest API reference, service helpers, profiles
  • Manifests — pair your flake with an mvm.toml for mvmctl init / build / up
  • Config & Secrets — inject config files and API keys at boot

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.