Skip to content

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.

  • 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 build fails with FEATURE_NOT_AVAILABLE. Images are built for linux/amd64.
  • An oxidevm.config.ts in your app whose mode matches the image: assets install reads it to stage the image. See Stage the engine and image files.
  1. Create a recipe directory. Give every recipe a directory of its own.

    Terminal window
    npx oxidevm image init images/python --mode userspace

    This writes a Dockerfile with a pinned Alpine base, the recipe oxidevm.image.ts, image.lock.json, a README.md, and ignore files that keep *.pem keys out of Git and Docker builds.

  2. In images/python/Dockerfile, keep the FROM line and add Python below it:

    RUN apk add --no-cache python3
  3. 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 init recorded the template’s id and version in image.lock.json. This recipe changes the id, so delete images/python/image.lock.json before the first build; the build writes a new one.

  4. 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.pem and its public trust anchor image-signing.public.json. It never overwrites existing files.

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

  6. 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 chromium

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

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

    With the default oxidevm.config.ts, the bundle goes to public/oxidevm/images/<manifest digest>/, next to the engine files.

  8. 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)") returns 42 and a newline. vm.image reports the running image’s id, version and manifestSHA256.

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.dev
Cross-Origin-Resource-Policy: cross-origin

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

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.

  • 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) holds algorithm ("Ed25519"), keyId and publicKey. You pass it to image test --trust and image 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_ERROR and detail.reason: "image-trust-changed". Open them with restorePoint: "update-image", which keeps the user’s /workspace files, or use a new workspaceId.

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.

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:

remote-image.ts
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:

default-elsewhere.ts
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"),
});
}

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.

prepared.ts
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.

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.

  1. Change the recipe and raise version. The next build stops with INVALID_ARGUMENT because image.lock.json still locks the old version; delete that file, as the message says, and build again.
  2. Test and stage the new bundle. It goes to a new images/<manifest digest>/ directory, and assets install rewrites the generated reference.
  3. 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.

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.