Build a local sandbox image
Select a local sandbox base image and add packages, files, setup commands, and ports for execution on your own hardware.
This guide uses the Sandbox SDK on your own hardware with local=True. Use an
Image to define the guest OS and setup steps before starting a local sandbox.
Start with Choose a base image.
For hosted execution, follow Choose a Fleet image. The local builder steps below do not publish a Fleet artifact or run during a claim.
SDK builder customization is local-only in cua-sandbox 0.7.0. apt_install(),
pip_install(), env(), run(), copy() and the other builder methods are applied when you
boot the image locally (local=True). Fleet runs a prebuilt registry artifact and rejects a
customized one with NotImplementedError: Fleet cloud supports registry images with optional exposed services only. For Fleet customization, prepare and publish the guest image
first. See Sandbox runtime
support for versioned acceptance and verification limits.
Choose a published Fleet image#
The hosted procedure is now in Choose a Fleet image. Use that guide for published artifacts, guest-service requirements, and Windows licensing requirements. Continue below only for local execution.
Choose a base image#
An Image describes the OS and software environment. Images are immutable and chainable. Each builder method returns a new Image.
Start with the built-in constructor that matches the operating system and isolation level you need.
from cua import Image
Image.linux() # Ubuntu 24.04 VM (default, QEMU)
Image.linux(kind='container') # Ubuntu 24.04 container (lighter, Docker/XFCE)
Image.linux('ubuntu', '22.04') # Older Ubuntu
Image.macos() # macOS Tahoe (version '26')
Image.macos('15') # macOS Sequoia
Image.windows() # Windows Server 2022
Image.windows('11') # Windows 11 (local ISO install only)
Image.android() # Android 14Choose a constructor using the OS and runtime table.
A constructor returning an Image does not verify that its guest can boot on
your host. Check local runtime requirements before selecting it.
If a local Docker image times out on readiness, check the guest server's listening
address. An earlier trycua/cua-xfce:latest recipe reported the server bound to
127.0.0.1:8000 inside the container, which Docker's published port could not reach.
That observation does not establish the state of every later mutable image tag.
How a Linux image runs locally#
Image.linux() is a VM. Locally it boots the same pinned containerDisk that Cua
Fleet references, under QEMU on the host. Sharing an artifact does not make host
networking, firmware, resources, or available SDK operations identical:
from cua import Image, Sandbox
async with Sandbox.ephemeral(Image.linux(), local=True) as sb:
print((await sb.shell.run('uname -a')).stdout)That path needs qemu-system-x86_64 on the host, and uses /dev/kvm when it is
available. Without QEMU the call raises rather than quietly starting something else:
RuntimeError: Image.linux() is a VM and needs QEMU, which was not found on this host.
Image.linux(kind='container') is the Docker path instead, and needs no QEMU.
Passing runtime=QEMURuntime() explicitly selects mode='docker', which wraps QEMU in a
container and does not boot the pinned containerDisk. Use QEMURuntime(mode='bare-metal') if
you are naming a runtime by hand, or leave runtime= off and let the default pick it.
Install packages#
Use the package manager for the target operating system.
Image.linux().apt_install('curl', 'git', 'ffmpeg')
Image.macos().brew_install('ffmpeg', 'jq')
Image.windows().choco_install('nodejs', 'git')
Image.windows().winget_install('Microsoft.VisualStudioCode')
Image.android().apk_install('/path/to/app.apk')Install Python packages with pip_install() or uv_install().
Image.linux().pip_install('numpy', 'pandas', 'playwright')
Image.linux().uv_install('numpy', 'pandas') # faster, installs into cua-server projectSet environment variables#
Set non-sensitive environment variables with .env().
Image.linux().env(DATABASE_URL='postgres://localhost/mydb', LOG_LEVEL='debug')Variables are baked in as /etc/profile.d/cua-env.sh on Linux and macOS, and as
machine-scoped setx variables on Windows. On Linux that file is read by login
shells, so a bare sb.shell.run() does not see them — ask for a login shell:
await sb.shell.run("echo $MY_TOKEN") # empty
await sb.shell.run("bash -lc 'echo $MY_TOKEN'") # abc123Avoid putting real secrets in .env() because anyone who can read the Image spec can see the values.
Run setup commands#
Run arbitrary shell commands during setup with .run().
Image.linux().run('curl -fsSL https://deb.nodesource.com/setup_20.x | bash -')Copy files#
Copy local files into the image with .copy().
Image.linux().copy('./config.json', '/app/config.json')Expose ports#
Expose every port the sandbox workload must listen on.
Image.linux().expose(8080).expose(5432)In 0.7.0, bare-metal QEMU forwards exposed ports at startup and reports their host ports in
sb.exposed_ports. Other local backends can differ. Fleet creates named services instead.
expose() does not make tunnel.forward() available on every transport; see ports and
transports.
Chain builder calls#
Chaining: builder calls are composable, each returns a new Image:
img = (
Image.linux()
.apt_install('curl', 'git', 'ffmpeg')
.pip_install('requests', 'Pillow')
.env(MY_TOKEN='abc123', DEBUG='1')
.run('mkdir -p /app/data')
.expose(8080)
)Fork a base image for different use cases:
base = Image.linux().apt_install('curl', 'git')
dev = base.pip_install('ipython', 'rich').env(DEBUG='1')
prod = base.pip_install('gunicorn').run('useradd -m appuser')Use a custom OCI image#
Use Image.from_registry() when your base image already exists in a registry.
The reference must be a KubeVirt containerDisk — an image carrying a bootable
disk at /disk/disk.img. An ordinary OCI application image such as ubuntu:22.04
is not one, and fails with
FileNotFoundError: OCI image 'ubuntu:22.04' does not contain /disk/disk.img.
img = Image.from_registry(
'registry.example/workspace@sha256:...',
os_type='linux',
kind='vm',
)
# Local setup only; Fleet rejects builder layers.
img = img.run('echo hi')A registry image defaults to kind=None, so the SDK cannot automatically select
a runtime unless you set the kind. For local use, select a runtime compatible
with the actual guest disk. The Linux VM example uses bare-metal QEMU:
from cua import QEMURuntime, Sandbox
async with Sandbox.ephemeral(img, local=True, runtime=QEMURuntime(mode='bare-metal')) as sb:
...Replace the example reference with an artifact you published. Image.from_registry() defaults to
os_type='linux'; set os_type='windows', kind='vm' for a Windows VM disk. The registry
reference does not prove that the artifact is accessible or bootable. Local pull authentication
and Fleet registry access are separate requirements.
Use a local disk image#
Use Image.from_file() for qcow2, vhdx, raw, or iso disk images. URLs are also supported and cached automatically.
Image.from_file('/path/to/disk.qcow2', os_type='linux')
Image.from_file('/path/to/windows.vhdx', os_type='windows')
Image.from_file('https://example.com/disk.qcow2', os_type='linux')Disk images downloaded from a URL are cached in ~/.cua/cua-sandbox/image-cache/,
keyed by a hash of the URL. This is a different cache from the one used for
registry containerDisks, which land in
~/.cua/cua-sandbox/images/container-disks/<sha256-of-ref>/disk.qcow2. Delete
either directory to force a re-download.
Inspect an image#
Call .to_dict() to inspect the final image spec before using it.
img = Image.linux().apt_install('curl').pip_install('requests')
print(img.to_dict())
# {'os_type': 'linux', 'distro': 'ubuntu', 'version': '24.04', 'kind': 'vm',
# 'layers': [{'type': 'apt_install', 'packages': ['curl']},
# {'type': 'pip_install', 'packages': ['requests']}]}env, ports and files keys appear only when you have set them:
img = Image.linux().env(DEBUG='1').expose(8080).copy('./a.json', '/app/a.json')
print(img.to_dict())
# {..., 'kind': 'vm', 'layers': [], 'env': {'DEBUG': '1'},
# 'ports': [8080], 'files': [['./a.json', '/app/a.json']]}