lima-vm
v0.1.0
Published
JavaScript SDK for Lima virtual machines
Maintainers
Readme
lima-vm
TypeScript SDK for Lima virtual machines. Functional, lean, zero dependencies.
Lima launches Linux VMs on macOS (and Linux) with automatic file sharing and port forwarding. This SDK wraps limactl with a clean programmatic API — no classes, just functions.
Install
npm install lima-vmQuick Start
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
const vm = await lima.create({
id: "my-vm",
cpus: 2,
memory: 4,
mounts: [{ src: "./project", dst: "/workspace", writable: true }],
})
await vm.start()
const result = await vm.exec("echo hello from linux")
console.log(result.stdout) // "hello from linux"
await vm.stop()
await vm.delete()Index
Guides
- Templates — built-in templates, custom YAML, full config reference
- AI Agent Sandbox — isolated environments for coding agents
- Node.js Development Environment — reproducible Node.js setup in a VM
- Docker — Docker without Docker Desktop, running in a lightweight VM
- Kubernetes (k3s) — local Kubernetes cluster for development
- CI/CD Matrix Testing — test across distros and versions in parallel
- Production Parity — match your Linux production environment on macOS
- Cross-Architecture Builds — build and test x86 binaries on ARM (and vice versa)
- Multi-VM Microservices — networked VMs simulating distributed systems
- Security Sandbox — run untrusted code in disposable VMs
API — Manage
lima.create()— create a new VMlima.get()— get a handle to an existing VMlima.list()— list all VMslima.info()— installation diagnosticsLima(options?)— create a lima instance (downloads binary if needed)
API — VM Handle
- Lifecycle —
start,stop,restart,delete vm.exec()— run a command, wait for resultvm.spawn()— long-running process with streamsvm.clone()— clone into a new VMvm.copy()— copy files host ↔ guestvm.edit()— edit configurationvm.rename()— rename VMvm.ssh()— SSH connection detailsvm.protect()/vm.unprotect()
API — Snapshots
vm.snapshot.create/apply/delete/list— VM state snapshots
API — Storage
lima.disk.create/delete/resize/list/import/unlock— disk management
API — Networking
lima.network.create/delete/list— virtual networksvm.tunnel()— SOCKS tunnel to guest
API — System
lima.templates()— list available templateslima.validate()— validate YAML templatelima.prune()— cleanup cachelima.sudoers()— generate sudoers configlima.watch()— event stream
API
Global
lima.create(options)
Create a new VM. Returns a VM handle.
const vm = await lima.create({
// Identity
id: "my-vm",
// Resources
cpus: 4,
memory: 4, // GiB
disk: 10, // GiB
arch: "aarch64", // "x86_64" | "aarch64" | "riscv64"
vmType: "vz", // "vz" | "qemu"
// Template (pick one)
template: "ubuntu", // built-in template name
yaml: "/path/to/config.yaml", // custom YAML file
url: "https://...", // remote YAML URL
// Mounts
mounts: [
{ src: "~/code", dst: "/code" },
{ src: "./project", dst: "/workspace", writable: true },
],
mountOnly: [
// replaces all default mounts
{ src: "./project", dst: "/workspace", writable: true },
],
mountType: "virtiofs", // "virtiofs" | "9p" | "reverse-sshfs"
mountInotify: true,
// Networking
networks: ["vzNAT"],
dns: ["8.8.8.8"],
portForwards: ["8080:80"],
// Features
rosetta: true, // x86 on ARM via Rosetta
nestedVirt: false,
containerd: "none", // "none" | "user" | "system"
plain: false, // no mounts, no port forwarding
// SSH
sshPort: 0, // 0 = random
// Provisioning
provision: [
{ mode: "system", script: "apt-get update && apt-get install -y git" },
{ mode: "user", script: "npm install -g @anthropic-ai/claude-code" },
],
// Advanced (raw yq expressions)
set: [".cpus = 8"],
})lima.get(id)
Get a handle to an existing VM.
const vm = await lima.get("my-vm")
await vm.exec("whoami")lima.list(options?)
List all VMs.
const vms = await lima.list()
// [{ id: "my-vm", status: "Running", cpus: 4, memory: 4294967296, ... }]
const running = await lima.list({ filter: '.status == "Running"' })lima.info()
Show diagnostic information about the Lima installation.
const info = await lima.info()lima.templates()
List available built-in templates.
const templates = await lima.templates()
// ["default", "docker", "ubuntu", "alpine", ...]lima.prune()
Remove unused cache data.
await lima.prune()lima.validate(path)
Validate a YAML template file.
await lima.validate("./my-template.yaml")Lima(options?)
Create a lima instance. Downloads the binary if not present.
import Lima from "lima-vm"
const lima = await Lima() // latest version
const lima = await Lima({ version: "2.1.0" }) // specific version
const lima = await Lima({ home: "/custom/path" }) // custom home dirVM Handle
Returned by create, get, and clone. All operations target this specific VM.
Lifecycle
await vm.start()
await vm.stop()
await vm.stop({ force: true })
await vm.restart()
await vm.delete()
await vm.delete({ force: true })vm.exec(command, options?)
Run a command and wait for completion.
const result = await vm.exec("ls -la /workspace")
// { stdout: "...", stderr: "...", exitCode: 0 }
const result = await vm.exec("npm test", {
cwd: "/workspace",
env: { NODE_ENV: "test", CI: "true" },
timeout: 60000,
shell: "/bin/bash",
})vm.spawn(command, options?)
Spawn a long-running process. Returns a handle with streams.
const proc = vm.spawn("npm run dev", {
cwd: "/workspace",
env: { PORT: "3000" },
})
proc.stdout.on("data", (chunk) => console.log(chunk.toString()))
proc.stderr.on("data", (chunk) => console.error(chunk.toString()))
proc.stdin.write("input\n")
const exitCode = await proc.exit
proc.kill()vm.copy(src, dst, options?)
Copy files between host and guest. Prefix guest paths with :.
await vm.copy("./local.txt", ":/tmp/file.txt") // host → guest
await vm.copy(":/etc/os-release", "./os-release.txt") // guest → host
await vm.copy("./dir", ":/workspace/", { recursive: true })
await vm.copy("./large", ":/data/", { recursive: true, backend: "rsync" })vm.clone(newId, options?)
Clone this VM into a new one. Returns a new VM handle.
const task = await vm.clone("task-123")
const task = await vm.clone("task-456", {
cpus: 2,
mountOnly: [{ src: "./other", dst: "/workspace", writable: true }],
})vm.edit(options?)
Edit configuration. VM must be stopped.
await vm.edit({ cpus: 8, memory: 8 })vm.rename(newId)
Rename this VM. Returns a new handle.
const renamed = await vm.rename("better-name")vm.protect() / vm.unprotect()
Protect from accidental deletion.
await vm.protect()
await vm.unprotect()vm.factoryReset()
await vm.factoryReset()vm.ssh(options?)
Get SSH connection details.
const ssh = await vm.ssh()
// { host: "127.0.0.1", port: 60022, user: "hugo.linux", key: "~/.lima/..." }
const cmd = await vm.ssh({ format: "cmd" })
// "ssh -p 60022 -i ~/.lima/... [email protected]"vm.status
Current status.
console.log(vm.status) // "Running" | "Stopped" | ...Snapshots
await vm.snapshot.list()
await vm.snapshot.create("clean-state")
await vm.snapshot.apply("clean-state")
await vm.snapshot.delete("clean-state")VM must be stopped for create and apply.
Disks
await lima.disk.list()
await lima.disk.create("data", { size: "50GiB", format: "qcow2" })
await lima.disk.resize("data", "100GiB")
await lima.disk.import("external", "/path/to/disk.qcow2")
await lima.disk.unlock("data")
await lima.disk.delete("data")Networks
Create and manage virtual networks for VM-to-VM communication.
// List networks
const networks = await lima.network.list()
// Create a network with a gateway
await lima.network.create("my-net", { gateway: "192.168.42.1/24" })
// Use it when creating VMs
const vm1 = await lima.create({ id: "vm1", networks: ["lima:my-net"] })
const vm2 = await lima.create({ id: "vm2", networks: ["lima:my-net"] })
// Delete
await lima.network.delete("my-net", { force: true })Tunnel
Create a SOCKS tunnel so the host can join the guest network.
const tunnel = await vm.tunnel({ port: 1080 })
// SOCKS proxy at localhost:1080 → guest network
tunnel.close()
// Random port
const tunnel = await vm.tunnel()
console.log(tunnel.port) // assigned portSudoers
Generate sudoers configuration for Lima (needed for vmnet).
const sudoers = await lima.sudoers()
// Write to /etc/sudoers.d/limaEvents
Watch events from instances. Returns an async iterable.
for await (const event of lima.watch()) {
console.log(event)
}
for await (const event of lima.watch("my-vm")) {
if (event.type === "stopped") break
}Templates
Built-in templates
Pass template when creating a VM to use a pre-configured setup:
const vm = await lima.create({ id: "dev", template: "docker" })
const vm = await lima.create({ id: "k8s", template: "k8s" })Available built-in templates:
| Category | Templates |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Default | default (Ubuntu) |
| Ubuntu | ubuntu, ubuntu-lts, ubuntu-20.04, ubuntu-22.04, ubuntu-24.04, ubuntu-24.10, ubuntu-25.04, ubuntu-25.10 |
| Debian | debian, debian-11, debian-12, debian-13 |
| Fedora | fedora, fedora-41, fedora-42, fedora-43 |
| Alpine | alpine, alpine-iso |
| Arch | archlinux |
| RHEL-family | almalinux, almalinux-8/9/10, rocky, rocky-8/9/10, centos-stream, centos-stream-9/10, oraclelinux, oraclelinux-8/9/10 |
| SUSE | opensuse, opensuse-leap, opensuse-leap-15/16 |
| Containers | docker, docker-rootful, podman, podman-rootful, containerd |
| Kubernetes | k3s, k8s, k0s |
| Build | buildkit |
| Other Linux | homebrew-linux, linuxbrew, experimental |
| Non-Linux | macos, macos-15, macos-26, freebsd, freebsd-15 |
| Special | apptainer, apptainer-rootful, faasd |
Run lima.templates() for the current list from your installation.
Custom templates
Create your own YAML template for repeatable environments:
# agent-sandbox.yaml
vmType: vz
arch: aarch64
cpus: 4
memory: 4GiB
disk: 10GiB
mounts: []
mountType: virtiofs
rosetta:
enabled: true
ssh:
forwardAgent: true
provision:
- mode: system
script: |
#!/bin/bash
apt-get update -qq
apt-get install -y -qq git curl build-essential
- mode: user
script: |
#!/bin/bash
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.nvm/nvm.sh
nvm install 22
npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cliUse it:
const vm = await lima.create({
id: "agent",
yaml: "./agent-sandbox.yaml",
})
// Or from a URL
const vm = await lima.create({
id: "agent",
url: "https://raw.githubusercontent.com/you/templates/main/agent-sandbox.yaml",
})Template YAML reference
Key configuration options for custom templates:
# VM type: "vz" (Apple Virtualization, fast) or "qemu" (cross-platform)
vmType: vz
# Architecture: "x86_64", "aarch64", or "default" (host arch)
arch: default
# Resources
cpus: 4 # default: min(4, host cores)
memory: 4GiB # default: min(4GiB, half host memory)
disk: 100GiB # default: 100GiB
# OS images (auto-selected from built-in templates)
images:
- location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img"
arch: "aarch64"
digest: "sha256:..."
# Mounts
mounts:
- location: "~"
writable: false
- location: "/tmp/lima"
writable: true
mountType: virtiofs # "virtiofs" | "9p" | "reverse-sshfs"
mountInotify: false # experimental inotify support
# Rosetta (x86 binary translation on ARM)
rosetta:
enabled: true
binfmt: true
# Networking
portForwards:
- guestPort: 8080
hostPort: 8080
networks:
- vzNAT: true # or "lima:shared" for vmnet
# SSH
ssh:
localPort: 0 # 0 = auto
forwardAgent: false # forward SSH agent to guest
# Containerd
containerd:
system: false
user: false
# Cloud-init provisioning scripts
provision:
- mode: system # run as root
script: |
#!/bin/bash
apt-get update
apt-get install -y git
- mode: user # run as user
script: |
#!/bin/bash
echo "export PATH=$PATH:~/.local/bin" >> ~/.bashrc
# Additional disks
additionalDisks:
- name: "data"
format: true
fsType: "ext4"
# Environment variables
env:
KEY: value
# CA certificates
caCerts:
removeDefaults: false
files:
- /path/to/cert.pemFull reference: lima-vm/lima/templates/default.yaml
AI Agent Sandbox
The primary use case — run coding agents with full permissions in complete isolation:
import Lima from "lima-vm"
const lima = await Lima()
// 1. Create a base image with all tools pre-installed
const base = await lima.create({
id: "agent-base",
cpus: 4,
memory: 4,
vmType: "vz",
mountType: "virtiofs",
rosetta: true,
})
await base.start()
await base.exec("sudo apt-get update -qq && sudo apt-get install -y -qq git curl build-essential")
await base.exec("curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash")
await base.exec(
"source ~/.nvm/nvm.sh && nvm install 22 && npm install -g @anthropic-ai/claude-code @openai/codex",
)
await base.stop()
await base.snapshot.create("ready")
// 2. Per task: clone base, mount project, run agent, cleanup
async function runAgent(taskId, projectPath, prompt) {
const vm = await base.clone(`task-${taskId}`, {
mountOnly: [{ src: projectPath, dst: "/workspace", writable: true }],
})
await vm.start()
try {
const proc = vm.spawn(`claude -p '${prompt}'`, {
cwd: "/workspace",
env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },
})
let output = ""
proc.stdout.on("data", (chunk) => {
output += chunk.toString()
})
const exitCode = await proc.exit
return { output, exitCode }
} finally {
await vm.stop()
await vm.delete({ force: true })
}
}
// 3. Run multiple agents in parallel
await Promise.all([
runAgent("1", "./myapp", "Fix the failing tests"),
runAgent("2", "./myapp", "Add authentication"),
runAgent("3", "./myapp", "Write API docs"),
])Why Lima for AI agents?
- Full isolation — agent can
rm -rf /and your host is safe - Real Linux — no Docker layer, no container quirks, real kernel
- Fast —
vz+virtiofsgives near-native performance (~2s boot) - File sharing — mount your project directory, agent sees real files
- Snapshots — snapshot a clean state, restore after each task
- Clone — clone a base VM with all tools pre-installed, instant spin-up
- Network control — restrict outbound if needed
Using a custom template for agents
# agent.yaml — save once, reuse forever
vmType: vz
cpus: 4
memory: 4GiB
disk: 10GiB
mountType: virtiofs
rosetta:
enabled: true
binfmt: true
mounts: []
provision:
- mode: system
script: |
#!/bin/bash
set -eux
apt-get update -qq
apt-get install -y -qq git curl build-essential
- mode: user
script: |
#!/bin/bash
set -eux
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.nvm/nvm.sh
nvm install 22
npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cliconst vm = await lima.create({
id: "agent-1",
yaml: "./agent.yaml",
mountOnly: [{ src: "./project", dst: "/workspace", writable: true }],
})
await vm.start()
await vm.exec("claude -p 'Fix the bug'", {
cwd: "/workspace",
env: { ANTHROPIC_API_KEY: "..." },
})Docker
Run Docker without Docker Desktop. No daemon on the host, no license fees, no bloat — just Docker running inside a lightweight Lima VM with automatic port forwarding and file sharing.
Quick start
import Lima from "lima-vm"
const lima = await Lima()
// Create a VM with Docker pre-installed
const vm = await lima.create({
id: "docker",
template: "docker",
cpus: 4,
memory: 4,
})
await vm.start()
// Docker is ready — run containers
const result = await vm.exec("docker run --rm hello-world")
console.log(result.stdout) // "Hello from Docker!"Run containers
// Pull and run
await vm.exec("docker pull nginx:alpine")
await vm.exec("docker run --rm -d --name web -p 8080:80 nginx:alpine")
// Port forwarding works automatically — access from your Mac:
// curl http://localhost:8080
// Check running containers
const ps = await vm.exec("docker ps")
console.log(ps.stdout)
// Stop
await vm.exec("docker stop web")Build images
Mount your project directory and build images inside the VM:
const vm = await lima.create({
id: "docker-dev",
template: "docker",
cpus: 4,
memory: 4,
mounts: [{ src: "./my-app", dst: "/workspace", writable: true }],
})
await vm.start()
// Build from your project's Dockerfile
const build = await vm.exec("docker build -t my-app .", { cwd: "/workspace" })
console.log(build.stdout)
// Run the built image
await vm.exec("docker run --rm -d -p 3000:3000 my-app")Docker Compose
// Mount project with docker-compose.yml
const vm = await lima.create({
id: "compose-dev",
template: "docker",
cpus: 4,
memory: 4,
mounts: [{ src: "./my-project", dst: "/workspace", writable: true }],
})
await vm.start()
// Start services
await vm.exec("docker compose up -d", { cwd: "/workspace" })
// Check logs
const logs = await vm.exec("docker compose logs --tail 50", { cwd: "/workspace" })
console.log(logs.stdout)
// Teardown
await vm.exec("docker compose down", { cwd: "/workspace" })Port forwarding
Lima forwards ports automatically. Any port a container listens on inside the VM is accessible from your host at localhost:<port>:
// Container ports are accessible from host
await vm.exec("docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16")
await vm.exec("docker run -d -p 6379:6379 redis:7")
// From your Mac:
// psql -h localhost -p 5432 -U postgres
// redis-cli -h localhost -p 6379For explicit port forwarding rules:
const vm = await lima.create({
id: "docker",
template: "docker",
portForwards: ["3000:3000", "8080:80"],
})Development workflow
Mount your project, run services, develop on your Mac with hot reload:
const vm = await lima.create({
id: "dev-env",
template: "docker",
cpus: 4,
memory: 8,
mounts: [{ src: "~/code/my-project", dst: "/workspace", writable: true }],
})
await vm.start()
// Start dev dependencies
await vm.exec("docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16")
await vm.exec("docker run -d -p 6379:6379 redis:7")
// Your app runs on your Mac, talks to localhost:5432 and localhost:6379
// Or run everything in the VM:
await vm.exec("docker compose up -d", { cwd: "/workspace" })Why not Docker Desktop?
| | Docker Desktop | Lima + lima-vm |
| -------------------- | --------------------------------- | ------------------------- |
| License | Paid for companies >250 employees | Free (Apache-2.0 + MIT) |
| Host daemon | Runs dockerd on your Mac | Nothing on host — VM only |
| Resource control | Global settings | Per-VM cpus/memory/disk |
| Isolation | Shared daemon | Separate VMs per project |
| Programmatic | Docker SDK (REST API) | TypeScript-native SDK |
| Snapshots | ✗ | ✓ snapshot & clone VMs |
| Cleanup | Prune commands | Delete the VM — gone |
Node.js Development Environment
Run Node.js projects in an isolated Linux VM — no nvm/fnm conflicts on the host, reproducible across machines, easy to snapshot and share.
Quick setup
import Lima from "lima-vm"
const lima = await Lima()
// Create a VM with Ubuntu
const vm = await lima.create({
id: "node-dev",
cpus: 4,
memory: 4,
mounts: [{ src: "./my-project", dst: "/workspace", writable: true }],
})
await vm.start()
// Install nvm + Node.js 22
await vm.exec("curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash")
await vm.exec("source ~/.nvm/nvm.sh && nvm install 22")
// Install package managers
await vm.exec("source ~/.nvm/nvm.sh && npm install -g pnpm yarn")
// Install dev tools
await vm.exec("source ~/.nvm/nvm.sh && npm install -g tsx vitest eslint prettier")
// Verify
const node = await vm.exec("source ~/.nvm/nvm.sh && node --version")
console.log(node.stdout.trim()) // v22.x.xMount your project and develop
// Your host project directory is mounted at /workspace
const result = await vm.exec("pnpm install", { cwd: "/workspace" })
// Run dev server — port forwarding works automatically
const dev = vm.spawn("pnpm dev", {
cwd: "/workspace",
env: { PORT: "3000" },
})
dev.stdout.on("data", (chunk) => console.log(chunk.toString()))
// Run tests inside the VM
const tests = await vm.exec("pnpm test", {
cwd: "/workspace",
env: { NODE_ENV: "test", CI: "true" },
})
console.log(tests.exitCode === 0 ? "✅ Tests passed" : "❌ Tests failed")Snapshot for reuse
Don't repeat the setup every time. Snapshot the configured environment and restore it instantly:
// After installing everything:
await vm.stop()
await vm.snapshot.create("node22-ready")
// Later — restore the clean environment
await vm.snapshot.apply("node22-ready")
await vm.start()
// Everything is exactly as you left it — Node.js, pnpm, tools, all thereReusable template
Save a YAML template so any team member (or CI) gets the same environment:
# node-dev.yaml
vmType: vz
arch: aarch64
cpus: 4
memory: 4GiB
disk: 20GiB
mountType: virtiofs
rosetta:
enabled: true
binfmt: true
mounts:
- location: "~"
writable: false
- location: "/tmp/lima"
writable: true
portForwards:
- guestPort: 3000
hostPort: 3000
- guestPort: 5173
hostPort: 5173
- guestPort: 8080
hostPort: 8080
provision:
- mode: system
script: |
#!/bin/bash
set -eux
apt-get update -qq
apt-get install -y -qq ca-certificates curl git build-essential
- mode: user
script: |
#!/bin/bash
set -eux
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.nvm/nvm.sh
nvm install 22
npm install -g pnpm yarn tsx vitest eslint prettierconst vm = await lima.create({
id: "node-dev",
yaml: "./node-dev.yaml",
mounts: [{ src: "./my-project", dst: "/workspace", writable: true }],
})
await vm.start()
// Ready to go — Node.js 22, pnpm, yarn, tsx, vitest, eslint, prettierWhy a VM instead of nvm/fnm?
- No host pollution — Node versions, global packages, and native deps stay in the VM
- Reproducible — same YAML template = same environment everywhere
- Snapshottable — save a configured state, restore in seconds
- Real Linux — test against the same OS as production
- Parallel versions — run Node 20 and Node 22 VMs side by side, no switching
- Shareable — commit the YAML template, team gets identical setup
Kubernetes (k3s)
Run a local Kubernetes cluster with k3s inside a Lima VM. No Docker Desktop, no minikube — just a lightweight single-node (or multi-node) K8s cluster that matches production.
Single-node cluster
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
const vm = await lima.create({
id: "k3s",
template: "k3s",
cpus: 4,
memory: 8,
})
await vm.start()
// k3s installs automatically — wait for it to be ready
await vm.exec("timeout 120 bash -c 'until kubectl get nodes | grep Ready; do sleep 2; done'")
// Deploy an app
await vm.exec("kubectl create deployment nginx --image=nginx:alpine")
await vm.exec("kubectl expose deployment nginx --port=80 --type=NodePort")
// Check it
const pods = await vm.exec("kubectl get pods")
console.log(pods.stdout)
// Get the kubeconfig for host-side kubectl
const kubeconfig = await vm.exec("cat /etc/rancher/k3s/k3s.yaml")
console.log(kubeconfig.stdout)Multi-node cluster
Spin up a control plane and worker nodes connected via Lima's user-v2 network:
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
// Control plane
const cp = await lima.create({
id: "k3s-cp",
template: "k3s",
cpus: 2,
memory: 4,
networks: ["lima:user-v2"],
})
await cp.start()
await cp.exec("timeout 120 bash -c 'until kubectl get nodes | grep Ready; do sleep 2; done'")
// Get join token and URL
const token = (await cp.exec("sudo cat /var/lib/rancher/k3s/server/node-token")).stdout.trim()
const url = `https://lima-k3s-cp.internal:6443`
// Worker node
const worker = await lima.create({
id: "k3s-w1",
template: "k3s",
cpus: 2,
memory: 4,
networks: ["lima:user-v2"],
set: [`.param.url = "${url}"`, `.param.token = "${token}"`],
})
await worker.start()
// Verify multi-node
await cp.exec(
"timeout 60 bash -c 'until kubectl get nodes | grep -c Ready | grep -q 2; do sleep 2; done'",
)
const nodes = await cp.exec("kubectl get nodes -o wide")
console.log(nodes.stdout)Helm + Ingress
// Install Helm inside the VM
await vm.exec(
"curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash",
)
// Deploy with Helm
await vm.exec("helm repo add bitnami https://charts.bitnami.com/bitnami")
await vm.exec("helm install my-redis bitnami/redis --set auth.enabled=false")
// Install ingress controller
await vm.exec(
"kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml",
)Why a VM for Kubernetes?
- Real cluster — k3s is production-grade Kubernetes, not a simulation
- Network isolation — cluster network doesn't pollute your host
- Multi-node — test leader election, pod scheduling, node failures
- Snapshots — snapshot a working cluster, restore after experiments
- Reproducible — same template = same cluster on any machine
- No Docker dependency — k3s bundles containerd, no Docker Desktop needed
CI/CD Matrix Testing
Test your code across multiple Linux distributions and versions in parallel. Each VM is a clean, isolated environment — no container limitations, real systemd, real kernel.
Test across distros
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
const distros = [
{ id: "ci-ubuntu", template: "ubuntu-24.04" },
{ id: "ci-debian", template: "debian" },
{ id: "ci-fedora", template: "fedora" },
{ id: "ci-alpine", template: "alpine" },
]
const projectPath = "./my-project"
async function testOn(distro) {
const vm = await lima.create({
...distro,
cpus: 2,
memory: 2,
mounts: [{ src: projectPath, dst: "/workspace" }],
})
try {
await vm.start()
// Install deps (distro-specific)
if (distro.template.startsWith("alpine")) {
await vm.exec("sudo apk add --no-cache nodejs npm git build-base")
} else if (distro.template.startsWith("fedora")) {
await vm.exec("sudo dnf install -y nodejs npm git gcc-c++ make")
} else {
await vm.exec(
"sudo apt-get update -qq && sudo apt-get install -y -qq nodejs npm git build-essential",
)
}
// Run tests
await vm.exec("npm ci", { cwd: "/workspace" })
const result = await vm.exec("npm test", {
cwd: "/workspace",
env: { CI: "true", NODE_ENV: "test" },
})
return { distro: distro.id, passed: result.exitCode === 0, output: result.stdout }
} finally {
await vm.stop({ force: true }).catch(() => {})
await vm.delete({ force: true }).catch(() => {})
}
}
// Run all in parallel
const results = await Promise.all(distros.map(testOn))
for (const r of results) {
console.log(`${r.passed ? "✅" : "❌"} ${r.distro}`)
}Snapshot-based fast CI
Create a base VM with all dependencies, snapshot it, then restore for each test run — much faster than reprovisioning:
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
// One-time setup: create and snapshot a ready environment
const base = await lima.create({
id: "ci-base",
cpus: 2,
memory: 4,
provision: [
{
mode: "system",
script: "apt-get update -qq && apt-get install -y -qq git curl build-essential",
},
{
mode: "user",
script: "curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash && source ~/.nvm/nvm.sh && nvm install 22",
},
],
})
await base.start()
await base.stop()
await base.snapshot.create("ready")
// Per test run: clone from base (instant, no reprovisioning)
async function ciRun(taskId, projectPath) {
const vm = await base.clone(`ci-${taskId}`, {
mountOnly: [{ src: projectPath, dst: "/workspace", writable: true }],
})
await vm.start()
try {
const install = await vm.exec("source ~/.nvm/nvm.sh && npm ci", { cwd: "/workspace" })
const test = await vm.exec("source ~/.nvm/nvm.sh && npm test", {
cwd: "/workspace",
env: { CI: "true" },
})
return { exitCode: test.exitCode, stdout: test.stdout, stderr: test.stderr }
} finally {
await vm.stop({ force: true }).catch(() => {})
await vm.delete({ force: true }).catch(() => {})
}
}Why VMs for CI?
- Real OS — test against actual Ubuntu/Fedora/Alpine, not a container layer
- Systemd support — test services, daemons, init scripts that need systemd
- Kernel features — test eBPF, cgroups, kernel modules,
/procand/sys - Clean state — each run starts fresh, no leftover state
- Parallel — run 4+ distros simultaneously on a single machine
- Reproducible — snapshot a working base, clone for each run
Production Parity
Develop on macOS but deploy to Linux? Lima gives you an identical Linux environment locally — same distro, same packages, same kernel. No more "works on my Mac" surprises.
Match your production stack
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
// Mirror your production Ubuntu 24.04 server
const vm = await lima.create({
id: "prod-mirror",
template: "ubuntu-24.04",
cpus: 4,
memory: 4,
vmType: "vz",
rosetta: true,
mounts: [{ src: "./my-app", dst: "/app", writable: true }],
provision: [
{
mode: "system",
script: `#!/bin/bash
set -eux
# Match production packages exactly
apt-get update -qq
apt-get install -y -qq \\
postgresql-client-16 \\
redis-tools \\
nginx \\
certbot \\
ffmpeg \\
imagemagick
# Same locale as production
locale-gen en_US.UTF-8
update-locale LANG=en_US.UTF-8
# Same timezone
timedatectl set-timezone UTC
`,
},
{
mode: "user",
script: `#!/bin/bash
set -eux
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.nvm/nvm.sh
nvm install 22
npm install -g pm2
`,
},
],
})
await vm.start()
// Run your app exactly as it runs in production
await vm.exec("source ~/.nvm/nvm.sh && npm ci --production", { cwd: "/app" })
const proc = vm.spawn("source ~/.nvm/nvm.sh && pm2-runtime start ecosystem.config.js", {
cwd: "/app",
env: { NODE_ENV: "production", PORT: "3000" },
})Reusable production template
# production.yaml — commit to your repo, everyone gets the same environment
vmType: vz
cpus: 4
memory: 4GiB
disk: 20GiB
mountType: virtiofs
rosetta:
enabled: true
binfmt: true
portForwards:
- guestPort: 3000
hostPort: 3000
- guestPort: 5432
hostPort: 5432
- guestPort: 6379
hostPort: 6379
provision:
- mode: system
script: |
#!/bin/bash
set -eux
apt-get update -qq
apt-get install -y -qq \
postgresql-16 postgresql-client-16 \
redis-server \
nginx \
git curl build-essential
# Start services
systemctl enable --now postgresql redis-server
# Create database
sudo -u postgres createuser -s $(whoami) 2>/dev/null || true
sudo -u postgres createdb myapp 2>/dev/null || true
- mode: user
script: |
#!/bin/bash
set -eux
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.nvm/nvm.sh
nvm install 22
npm install -g pm2 pnpmconst vm = await lima.create({
id: "prod",
yaml: "./production.yaml",
mounts: [{ src: "./my-app", dst: "/app", writable: true }],
})
await vm.start()
// PostgreSQL, Redis, and nginx are running — just like production
await vm.exec("source ~/.nvm/nvm.sh && pnpm install", { cwd: "/app" })
await vm.exec("source ~/.nvm/nvm.sh && pnpm db:migrate", { cwd: "/app" })
await vm.exec("source ~/.nvm/nvm.sh && pnpm dev", { cwd: "/app" })Why VMs for production parity?
- Real systemd — test service files, process managers, init scripts
- Real kernel — no Docker-for-Mac kernel quirks, test kernel-level features
- Full services — run PostgreSQL, Redis, nginx natively, not in containers
- Same distro — Ubuntu 24.04 locally = Ubuntu 24.04 in production
- Network stack — real iptables, real DNS resolution, real
/etc/hosts - File permissions — Linux file ownership and permissions work correctly
Cross-Architecture Builds
Build and test x86_64 binaries on Apple Silicon (or ARM binaries on Intel). Lima supports Rosetta for near-native x86 performance on ARM Macs, and QEMU for full cross-arch emulation.
Fast x86 builds with Rosetta
On Apple Silicon Macs, Rosetta translates x86_64 instructions at near-native speed:
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
const vm = await lima.create({
id: "x86-builder",
cpus: 4,
memory: 4,
vmType: "vz",
rosetta: true, // Enable Rosetta translation
mounts: [{ src: "./my-project", dst: "/workspace", writable: true }],
})
await vm.start()
// Install x86_64 toolchain
await vm.exec("sudo dpkg --add-architecture amd64")
await vm.exec(
"sudo apt-get update -qq && sudo apt-get install -y -qq gcc-x86-64-linux-gnu g++-x86-64-linux-gnu",
)
// Build x86_64 binary from ARM host
await vm.exec("x86_64-linux-gnu-gcc -o myapp-amd64 main.c", { cwd: "/workspace" })
// Test it — Rosetta runs it transparently
const result = await vm.exec("./myapp-amd64", { cwd: "/workspace" })
console.log(result.stdout)Multi-arch container images
Build container images for both architectures using buildx:
const vm = await lima.create({
id: "multiarch",
template: "docker",
cpus: 4,
memory: 4,
rosetta: true,
mounts: [{ src: "./my-app", dst: "/workspace", writable: true }],
})
await vm.start()
// Set up buildx for multi-platform builds
await vm.exec("docker buildx create --name multiarch --use")
await vm.exec("docker buildx inspect --bootstrap")
// Build for both architectures
await vm.exec("docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest --load .", {
cwd: "/workspace",
})
// Test the x86 image on ARM (Rosetta handles it)
await vm.exec("docker run --platform linux/amd64 --rm myapp:latest ./run-tests")Full cross-arch VM with QEMU
For architectures Rosetta doesn't support (RISC-V, PPC64), use QEMU system emulation:
// Run a full x86_64 VM on an ARM Mac via QEMU
const vm = await lima.create({
id: "qemu-x86",
arch: "x86_64",
vmType: "qemu",
cpus: 2,
memory: 2,
plain: true, // Disable mounts/port-forwarding for stability
})
await vm.start()
// This is a real x86_64 Linux environment
const uname = await vm.exec("uname -m")
console.log(uname.stdout.trim()) // "x86_64"Why VMs for cross-arch?
- Rosetta speed — x86 on ARM at near-native performance (~80-90%)
- Real binaries — test actual x86 executables, not just container layers
- CI validation — verify your ARM Mac builds work on x86 before deploying
- Multi-arch images — build Docker images for both architectures locally
- No cross-compile hassle — run in the target architecture natively
Multi-VM Microservices
Test distributed systems by running each service in its own VM, connected via Lima's virtual networks. Realistic network conditions, separate IP addresses, independent failure domains.
Service mesh with virtual networking
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
// Create a shared network
await lima.network.create("services", { gateway: "192.168.50.1/24" })
// API service
const api = await lima.create({
id: "svc-api",
cpus: 2,
memory: 2,
networks: ["lima:services"],
mounts: [{ src: "./api", dst: "/app", writable: true }],
provision: [
{ mode: "system", script: "apt-get update -qq && apt-get install -y -qq curl" },
{
mode: "user",
script: `curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.nvm/nvm.sh && nvm install 22`,
},
],
})
// Database service
const db = await lima.create({
id: "svc-db",
cpus: 2,
memory: 4,
networks: ["lima:services"],
provision: [
{
mode: "system",
script: `apt-get update -qq && apt-get install -y -qq postgresql-16
systemctl enable --now postgresql
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'dev'"
sudo -u postgres psql -c "CREATE DATABASE myapp"
# Listen on all interfaces
sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" /etc/postgresql/16/main/postgresql.conf
echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/16/main/pg_hba.conf
systemctl restart postgresql`,
},
],
})
// Cache service
const cache = await lima.create({
id: "svc-cache",
cpus: 1,
memory: 1,
networks: ["lima:services"],
provision: [
{
mode: "system",
script: `apt-get update -qq && apt-get install -y -qq redis-server
sed -i 's/bind 127.0.0.1/bind 0.0.0.0/' /etc/redis/redis.conf
systemctl restart redis-server`,
},
],
})
// Start all
await Promise.all([api.start(), db.start(), cache.start()])
// Services can reach each other via hostname:
// lima-svc-db.internal, lima-svc-cache.internal
await api.exec(`source ~/.nvm/nvm.sh && npm ci && npm start`, {
cwd: "/app",
env: {
DATABASE_URL: "postgresql://postgres:[email protected]:5432/myapp",
REDIS_URL: "redis://lima-svc-cache.internal:6379",
},
})Failure testing
// Simulate database failure
await db.stop({ force: true })
// Does the API handle it gracefully?
const health = await api.exec("curl -sf http://localhost:3000/health || echo 'unhealthy'")
console.log(health.stdout) // Should show degraded state, not crash
// Bring it back
await db.start()
// Simulate network partition via iptables
await api.exec("sudo iptables -A OUTPUT -d lima-svc-cache.internal -j DROP")
// Test how API behaves without cache...
await api.exec("sudo iptables -D OUTPUT -d lima-svc-cache.internal -j DROP")Cleanup
// Tear down everything
await Promise.all([
api.stop({ force: true }).then(() => api.delete({ force: true })),
db.stop({ force: true }).then(() => db.delete({ force: true })),
cache.stop({ force: true }).then(() => cache.delete({ force: true })),
])
await lima.network.delete("services", { force: true })Why VMs for microservices?
- Real networking — each VM gets its own IP, real DNS, real TCP/UDP
- Independent failure — stop/crash one service without affecting others
- Resource isolation — each service gets its own CPU/memory allocation
- Production-like — closer to real server deployment than Docker Compose
- Network testing — simulate partitions, latency, DNS failures with iptables
- Full OS — each service runs in a real Linux with systemd, cron, syslog
Security Sandbox
Run untrusted code, scripts, or binaries in a completely isolated disposable VM. The host filesystem is never exposed, the VM has no write access to anything on the host, and you can destroy it instantly.
Run untrusted code safely
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
async function runUntrusted(code, language = "python3") {
const vm = await lima.create({
id: `sandbox-${Date.now()}`,
cpus: 1,
memory: 1,
plain: true, // No mounts, no port forwarding — total isolation
provision: [
{ mode: "system", script: `apt-get update -qq && apt-get install -y -qq ${language}` },
],
})
try {
await vm.start()
// Copy the code into the VM (no shared filesystem)
await vm.exec(`cat > /tmp/run.py << 'SCRIPT'\n${code}\nSCRIPT`)
// Run with resource limits
const result = await vm.exec("timeout 30 python3 /tmp/run.py", { timeout: 35000 })
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
}
} finally {
// Destroy immediately — no traces left
await vm.stop({ force: true }).catch(() => {})
await vm.delete({ force: true }).catch(() => {})
}
}
// Safe to run anything — it can't escape the VM
const result = await runUntrusted(`
import os
print("I'm in:", os.getcwd())
print("User:", os.getenv("USER"))
# Even malicious code can't touch the host
`)
console.log(result.stdout)Pre-built sandbox pool
For lower latency, maintain a pool of ready-to-use sandbox VMs using snapshots:
import Lima from "lima-vm"
const lima = await Lima({ version: "2.1.0" })
// One-time: create and snapshot a sandbox base
const base = await lima.create({
id: "sandbox-base",
cpus: 1,
memory: 1,
plain: true,
provision: [
{
mode: "system",
script: `#!/bin/bash
apt-get update -qq
apt-get install -y -qq python3 python3-pip nodejs npm ruby gcc g++ rustc
# Lock down network if desired
# iptables -A OUTPUT -j DROP
`,
},
],
})
await base.start()
await base.stop()
await base.snapshot.create("clean")
// Per execution: clone from base (fast, seconds not minutes)
async function quickSandbox(command) {
const vm = await base.clone(`sandbox-${Date.now()}`)
await vm.start()
try {
return await vm.exec(command, { timeout: 30000 })
} finally {
await vm.stop({ force: true }).catch(() => {})
await vm.delete({ force: true }).catch(() => {})
}
}
// Runs in seconds, not minutes
await quickSandbox("python3 -c 'print(sum(range(1000)))'")
await quickSandbox("node -e 'console.log(Array.from({length:10},(_,i)=>i*i))'")
await quickSandbox("ruby -e 'puts (1..10).select(&:odd?)'")Network-restricted sandbox
const vm = await lima.create({
id: "air-gapped",
cpus: 1,
memory: 1,
plain: true,
provision: [
{
mode: "system",
script: `#!/bin/bash
apt-get update -qq
apt-get install -y -qq python3
# Cut all outbound network access after setup
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -j DROP
`,
},
],
})
await vm.start()
// Code runs with zero network access
const result = await vm.exec(
"python3 -c 'import urllib.request; urllib.request.urlopen(\"https://evil.com\")'",
)
console.log(result.exitCode) // Non-zero — network blockedWhy VMs for sandboxing?
- Hardware-level isolation — VM boundary, not just kernel namespaces
- No host filesystem —
plain: truemeans zero mounts - Disposable — delete the VM, everything is gone
- Network control — cut network access entirely with iptables
- Resource limits — cap CPU, memory, disk per sandbox
- Root is safe — untrusted code can be root inside the VM, can't touch host
- Snapshots — restore to clean state instantly
Docker Sandbox Performance
Using the "Docker inside Lima" pattern — one VM with Docker, containers as disposable sandboxes.
Benchmarked on Apple M4 (10 cores, 16 GB RAM), VM: 4 CPUs / 4 GiB, macOS Darwin 25.3.0
| Operation | Median | P90 | Overhead vs raw exec |
| ------------------------------------ | ------ | ----- | -------------------- |
| Raw VM exec (echo) | 35ms | 44ms | baseline |
| Container cold start (alpine echo) | 173ms | 252ms | +138ms |
| Node.js container (node -e) | 193ms | 225ms | +158ms |
| Python container (python -c) | 185ms | 225ms | +150ms |
| Docker build + run | 257ms | 401ms | +223ms |
Key takeaway: Docker containers inside a Lima VM add ~140–220ms of overhead on top of the ~35ms SSH baseline. A full docker run with an Alpine image completes in under 200ms median — fast enough for disposable sandbox use cases.
Full results: bench/docker-sandbox-results.md
Design
- Functional —
lima.create()returns a handle, not an instance of a class - Handle pattern — create/get once, call methods without repeating the id
- Lean — thin wrapper around
limactl, no hidden abstractions - Typed — full TypeScript types for all options and return values
- Zero dependencies — just
child_processfrom Node stdlib - Composable —
clone()andrename()return new handles
License
MIT
