Skip to main content
External Linux sandboxes

Docker

Give each Rivet Actor its own Docker container for local development.

Docker runs containers on the node itself, so it needs no account and no network round trip. Through @rivet-dev/sandbox-adapter, each Actor creates a container, runs commands and uses files in it, stops it while the Actor sleeps, and removes it when the Actor is destroyed. Use it for local development, and switch the import to a cloud provider for production.

Clientbuildstores the sandbox idDockercontaineractionsexec, filesOne per buildRivet ActorCall

Quickstart

Install

The provider runs the docker CLI on the node and uses the current Docker context, so Docker must be installed and running. No extra package is needed.

npm install rivetkit @rivet-dev/sandbox-adapter

Define the Actor

Each build Actor owns one container. The first run, saveFile, or readFile creates it; later calls connect to the same container. This example joins the none network, so commands have no network access.

import { posix } from "node:path";
import type { Sandbox, SandboxActorContext } from "@rivet-dev/sandbox-adapter";
import { dockerProvider } from "@rivet-dev/sandbox-adapter/docker";
import { actor, event, setup } from "rivetkit";

// One provider object serves every Actor of this definition.
const provider = dockerProvider({ image: "ubuntu:24.04", network: "none" });

interface BuildState {
	sandboxId: string | null;
}

export const build = actor({
	state: { sandboxId: null } as BuildState,
	events: {
		// Command output, as it arrives.
		output: event<string>(),
	},
	actions: {
		run: async (c, command: string) => {
			const sandbox = await openSandbox(c, c.state);
			const decoder = new TextDecoder();
			return sandbox.exec(command, {
				cwd: sandbox.cwd,
				timeoutMs: 60_000,
				// Stops the command if the Actor shuts down.
				signal: c.abortSignal,
				onData: (chunk) => c.broadcast("output", decoder.decode(chunk, { stream: true })),
			});
		},
		saveFile: async (c, path: string, content: string) => {
			const sandbox = await openSandbox(c, c.state);
			const target = posix.resolve(sandbox.cwd, path);
			await sandbox.mkdir(posix.dirname(target));
			await sandbox.writeFile(target, content);
		},
		readFile: async (c, path: string) => {
			const sandbox = await openSandbox(c, c.state);
			const bytes = await sandbox.readFile(posix.resolve(sandbox.cwd, path));
			return new TextDecoder().decode(bytes);
		},
	},
	// The sandbox stops while the Actor sleeps and is removed along with the Actor.
	onSleep: async (c) => {
		if (c.state.sandboxId) await provider.suspend?.(c, c.state.sandboxId);
	},
	onDestroy: async (c) => {
		if (c.state.sandboxId) await provider.destroy?.(c, c.state.sandboxId);
	},
});

// Connects to this Actor's sandbox. The first call creates it.
async function openSandbox(c: SandboxActorContext, state: BuildState): Promise<Sandbox> {
	const existing = state.sandboxId ? await provider.connect(c, state.sandboxId) : undefined;
	if (existing) return existing;
	state.sandboxId = await provider.create(c);
	const sandbox = await provider.connect(c, state.sandboxId);
	if (!sandbox) throw new Error(`Sandbox ${state.sandboxId} was not found right after it was created.`);
	return sandbox;
}

export const registry = setup({ use: { build } });

registry.start();

Options

OptionDescription
imageImage to run. Defaults to ubuntu:24.04.
cwdWorking directory inside the container. Created when the Actor connects. Defaults to /workspace.
envEnvironment variables of the container. The node’s environment is never passed in.
networkDocker network to join. Set "none" for no network access. Defaults to bridge. The host network is rejected.
portsContainer ports to publish on a random 127.0.0.1 port. Read the address with dockerPortAddress(sandboxId, port).
keepImageEntrypointRuns the image’s own entrypoint, for images that start a server. By default the container runs an idle process.
dockerPath of the Docker CLI. Defaults to docker.

Each sandbox is one container with the label dev.rivet.sandbox-adapter.sandbox-id. The container has no host mounts, no host environment, no privileged mode, and no host network. suspend stops the container while the Actor sleeps, and destroy removes it.

Image requirements

The provider has no agent inside the container. It runs every operation through /bin/sh and a few command-line tools from the image: tail, mkdir, cat, rm, and touch, plus a writable /tmp. ubuntu:24.04, debian:bookworm-slim, alpine:3.22, and busybox:1.37 have all of them. Check your own image with:

docker run --rm --entrypoint /bin/sh my-image -c 'for t in tail cat mkdir rm touch; do command -v "$t" >/dev/null || echo "missing: $t"; done; [ -w /tmp ] || echo "not writable: /tmp"'

A non-root USER needs a cwd it owns, such as /home/app, because Docker creates the default /workspace as root. Commands can only use what the image has, such as git or node.

The Sandbox API, lifecycle, and the other providers are in the sandbox adapter guide.