Custom images
Every VM runs an image: a signed Linux root filesystem, plus a kernel and a disk for System. Userspace uses the default image pinned by your SDK when you leave out image. System requires an image that you supply. If your app needs a program the default Userspace image does not include, such as python3, build your own image and pass it to createVM.
You build images on your machine or in CI with the oxidevm CLI and Docker. Your users’ browsers only download the finished files, and the SDK checks them against your signing key before guest programs can use them.
For managed System images built from a directory, archive, OCI image or
Dockerfile, the builder prepares the native workspace baseline before packaging.
This avoids scanning the initial workspace during guest startup. Rebuild the
image to apply this to an older image. A boot-files recipe preserves the disk
you supply, including its guest integration and baseline.
Before you start
Section titled “Before you start”- Node.js 24 or later, and the CLI:
npm install --save-dev @oxidevm/cli. - Docker with Buildx and a running BuildKit builder, for images built from a Dockerfile or an OCI image. Without one,
image buildfails withFEATURE_NOT_AVAILABLE. Images are built forlinux/amd64. - An
oxidevm.config.tsin your app whosemodematches the image:assets installreads it to stage the image. See Stage the engine and image files.
Build a Python image
Section titled “Build a Python image”-
Create a recipe directory. Give every recipe a directory of its own.
Terminal window npx oxidevm image init images/python --mode userspaceThis writes a
Dockerfilewith a pinned Alpine base, the recipeoxidevm.image.ts,image.lock.json, aREADME.md, and ignore files that keep*.pemkeys out of Git and Docker builds. -
In
images/python/Dockerfile, keep theFROMline and add Python below it:RUN apk add --no-cache python3 -
Replace the recipe with one for your image:
images/python/oxidevm.image.ts import type { UserspaceImageRecipe } from "@oxidevm/sdk/image-builder";export default {mode: "userspace",id: "my-python",version: "1.0.0",sourceDateEpoch: 0,source: { kind: "dockerfile", context: ".", file: "Dockerfile" },resources: { memoryMiB: 512 },smokeTest: {command: "python3",args: ["-c", "print('Python image ready')"],expectedExitCode: 0,stdoutIncludes: "Python image ready",},} satisfies UserspaceImageRecipe;image initrecorded the template’sidandversioninimage.lock.json. This recipe changes theid, so deleteimages/python/image.lock.jsonbefore the first build; the build writes a new one. -
Create a signing key once, outside the project:
Terminal window mkdir -p "$HOME/.config/oxidevm"npx oxidevm image keygen --out "$HOME/.config/oxidevm/image-signing.pem"This writes the private key
image-signing.pemand its public trust anchorimage-signing.public.json. It never overwrites existing files. -
Build and sign the image:
Terminal window npx oxidevm image build images/python/oxidevm.image.ts --signing-key "$HOME/.config/oxidevm/image-signing.pem"The signed bundle lands in
images/python/dist/. The command prints a JSON report with the manifest digest (manifestSHA256) and the bundle path. -
Test the image in a real browser:
Terminal window npx oxidevm image test images/python/dist --trust "$HOME/.config/oxidevm/image-signing.public.json" --browser chromiumThe test opens a VM with the image, checks files, a terminal and persistence, and runs your smoke test. It exits with status 3 when a check fails. A successful Docker build alone does not show that the programs run in the browser.
-
Stage the image in your app and generate a reference module:
Terminal window npx oxidevm assets install --image images/python/dist --reference src/generated/python.image.tsWith the default
oxidevm.config.ts, the bundle goes topublic/oxidevm/images/<manifest digest>/, next to the engine files. -
Pass the reference to
createVM:src/python.ts import { createVM } from "@oxidevm/sdk";import pythonImage from "./generated/python.image.js";export async function runPython(code: string): Promise<string> {const vm = await createVM({mode: "userspace",runtime: { hosting: "frame" },image: pythonImage,});try {const result = await vm.exec({ command: "python3", args: ["-c", code] });if (result.exit.kind !== "exited" || result.exit.code !== 0) {throw new Error(new TextDecoder().decode(result.stderr));}return new TextDecoder().decode(result.stdout);} finally {await vm.dispose();}}runPython("print(6 * 7)")returns42and a newline.vm.imagereports the running image’sid,versionandmanifestSHA256.
Images and the runtime frame
Section titled “Images and the runtime frame”With the hosted runtime frame, the frame downloads the image, so to the frame your site is another origin. Serve the image files with these headers:
Access-Control-Allow-Origin: https://run.oxidevm.devCross-Origin-Resource-Policy: cross-originA manifestUrl that is a path, such as the one in a generated reference, is resolved against your page before the frame fetches it. A self-hosted engine downloads images from your page, so images on your own origin need no extra headers.
Recipe fields
Section titled “Recipe fields”| Field | Meaning |
|---|---|
mode |
"userspace" (Userspace) or "system" (System). Required. |
id, version |
Lowercase letters, digits, ., _ and -, starting with a letter or digit, at most 64 characters. Required. |
source |
Where the root filesystem comes from; see the next table. Required. |
smokeTest |
The command image test runs: command, optional args and cwd, expectedExitCode: 0, and optional stdoutIncludes. Required. |
resources |
Userspace: memoryMiB (256 to 3072) and maxProcesses (1 to 64). System: memoryMiB and vcpus. Options you pass to createVM take precedence. |
sourceDateEpoch |
The Unix time, in seconds, that the builder sets file times to. image.lock.json records it. |
workspace |
Userspace only: { ownership: { uid, gid } } for /workspace and everything below it. By default it belongs to uid and gid 1000, the user Userspace processes run as. |
boot, disk |
System only: boot.kernel, optional boot.initrd and boot.commandLine, and disk.sizeMiB (32 to 8192). |
evidence |
SBOM, notices, corresponding sources and license policy files for distribution builds. |
source.kind |
Fields | Modes |
|---|---|---|
dockerfile |
context, file, optional target and buildArgs |
Both |
oci |
reference, a Docker image reference; distribution builds need an @sha256: digest |
Both |
directory |
path to a root filesystem you prepared |
Both |
archive |
artifact: { path, sha256 } |
Both |
boot-files |
kernel, disk and optional initrd, each { path, sha256 }, plus commandLine |
System |
For a System image, run image init with --mode system. Its recipe adds boot: { kernel: { kind: "managed-default" } } and disk: { sizeMiB: 1024 }; a kernel file can be given as { kind: "file", artifact: { path, sha256 } }. Stage it with an oxidevm.config.ts whose mode is "system", and create the VM with mode: "system". A reference carries its mode: a Userspace image passed to a System VM, or the reverse, rejects with INVALID_ARGUMENT.
Signing keys and trust anchors
Section titled “Signing keys and trust anchors”- The private key (
.pem) signs images. Keep it out of the repository and never send it to the browser. Reuse the same key for every version of an image. - The trust anchor (
.public.json) holdsalgorithm("Ed25519"),keyIdandpublicKey. You pass it toimage test --trustandimage inspect --trust, and the generated reference carries it into your app. - Before a VM starts, the SDK verifies the signed manifest against that anchor, and it checks each file against the manifest before guest programs read it. Never load the key from the server that hosts the image, or whoever controls that server could swap the image.
- After a key change, durable Userspace workspaces saved on an image signed by the old key fail to open with
PROTOCOL_ERRORanddetail.reason: "image-trust-changed". Open them withrestorePoint: "update-image", which keeps the user’s/workspacefiles, or use a newworkspaceId.
assets install --reference writes a module that calls defineImage with the image’s mode, its manifestUrl under your asset base, its manifestSHA256 and your trust anchor. Do not edit it; run assets install again instead. The command refuses to overwrite a file it did not generate.
Write a reference yourself
Section titled “Write a reference yourself”To use a bundle you host elsewhere, call defineImage with the same four fields. Take manifestSHA256 from the image build report and trust from your .public.json, and build both into your app’s code:
import { createVM, defineImage, type TrustedImageKey } from "@oxidevm/sdk";
export function openRemotePython(manifestSHA256: string, trust: TrustedImageKey) { const image = defineImage({ mode: "userspace", manifestUrl: "https://images.example.com/my-python/1.0.0/manifest.json", manifestSHA256, trust, }); return createVM({ mode: "userspace", runtime: { hosting: "frame" }, image });}manifestUrl must use HTTPS (HTTP only on loopback addresses) or be a path that starts with /. Pass the object defineImage returns: createVM rejects a copy of it. Deploy the complete bundle directory (manifest, lock, blobs, packs and evidence), and have that server send the headers from Images and the runtime frame. With a self-hosted engine and a bundle on another origin, Access-Control-Allow-Origin names your app instead.
defaultImage(mode, manifestUrl?) returns the reference createVM uses when you leave out image: the pinned digest and key, at the default address or at manifestUrl. Use it to serve an unchanged copy of the staged default image from another address:
import { createVM, defaultImage } from "@oxidevm/sdk";
export function openWithCopiedDefault() { return createVM({ mode: "userspace", runtime: { hosting: "frame" }, image: defaultImage("userspace", "https://cdn.example.com/oxidevm/default-image/manifest.json"), });}Install the whole image before start
Section titled “Install the whole image before start”By default (imagePreparation: "on-demand"), a VM downloads image files as programs read them. With imagePreparation: "before-ready", createVM first installs the complete image in browser storage. Later reads, saves and restores then use that local copy, and a missing part fails with detail.reason: "image-not-cached" instead of starting a download.
import { createVM } from "@oxidevm/sdk";import pythonImage from "./generated/python.image.js";
export function openPrepared(show: (text: string) => void) { return createVM({ mode: "userspace", runtime: { hosting: "frame" }, image: pythonImage, imagePreparation: "before-ready", onProgress: (progress) => { if (progress.phase === "prepare-image") { show(`${progress.stage}: ${progress.completedBytes} of ${progress.totalBytes} bytes`); } }, });}progress.stage is "download", "verify" or "commit". Installed images are stored by manifest digest and shared by every VM on your origin. vm.prepareImage({ signal }) prepares or repairs the running image later without restarting the guest: it keeps the image version, processes and terminals, downloads only missing or damaged files, and keeps verified progress if you cancel.
Inspect and pack bundles
Section titled “Inspect and pack bundles”| Command | What it does |
|---|---|
oxidevm image inspect <bundle> |
Read-only integrity check. Without --trust, the report marks authentication as unverified. |
oxidevm image pack <bundle> --trust <file> |
Adds pack files to an older Userspace bundle, next to its unchanged manifest. Publish the new files in the original image directory and keep the old ones. |
See CLI for every flag.
Ship a new version
Section titled “Ship a new version”- Change the recipe and raise
version. The next build stops withINVALID_ARGUMENTbecauseimage.lock.jsonstill locks the old version; delete that file, as the message says, and build again. - Test and stage the new bundle. It goes to a new
images/<manifest digest>/directory, andassets installrewrites the generated reference. - Keep the earlier image directories deployed. Restore points and durable workspaces can refer to the image they were made on.
A durable workspace can keep running the image it was saved on. vm.image tells you which image runs, vm.start.update describes the configured image when it differs, and creating the VM with restorePoint: "update-image" moves the workspace to it. In Userspace, the user’s /workspace changes come along, and vm.start.workspaceDetails.state reports "in-sync", "reconciled" or "conflict-preserved". See Restore points.
Licenses of the software in an image
Section titled “Licenses of the software in an image”A development build reports distribution.kind: "development" and lists the evidence it lacks. Before you distribute an image, add the recipe’s evidence files (SBOM, notices, corresponding sources and license policy), then run image build --distribution and image test --distribution with the same recipe, key and trust. A distribution build also checks that the build reproduces.