For why the networking modes behave the way they do, read Networking. For the trust model and the tradeoffs behind these defaults, read Security.
One image serves everything — API, maintenance commands, migrations.
It's published to ghcr.io/czpython/drukbox on every release; build
docker build -t ghcr.io/czpython/drukbox . only to run a local change.
IMAGE=ghcr.io/czpython/drukbox:latest
# API (port 8780; /healthz for liveness probes)
docker run --rm -p 8780:8780 --env-file drukbox.env "$IMAGE"
# Migrations (one-off, before first start and on upgrades)
docker run --rm --env-file drukbox.env "$IMAGE" .venv/bin/alembic upgrade head
# Maintenance (cron, e.g. every 10-15 min)
docker run --rm --env-file drukbox.env "$IMAGE" .venv/bin/python -m hosts.janitor
docker run --rm --env-file drukbox.env "$IMAGE" .venv/bin/python -m hosts.poolThe janitor reaps expired and orphaned hosts. The pool maintainer
pre-provisions warm hosts per provider and only does anything when at
least one provider has a warm target (POOL_SIZES / POOL_SIZE).
Schedule both under your cron infrastructure (k8s CronJob, systemd
timer) from the same image and env file.
Use Postgres in production (postgresql+psycopg://...). SQLite
(sqlite+aiosqlite:///./drukbox.db) is for single-process demos and
local development; the pool maintainer is safe under SQLite only with
a single runner.
The API binds all interfaces by default. When only loopback callers
reach it (host-networked, co-located client), set UVICORN_HOST=127.0.0.1
to keep the credential-holding control plane off other interfaces.
| Provider | Sandboxes | Where |
|---|---|---|
exe |
exe.dev VMs | Remote |
aws |
EC2 instances | Remote |
hetzner |
Hetzner Cloud VMs | Remote |
exoscale |
Exoscale VMs | Remote |
docker |
Containers (Local sandboxes with Docker) | Local, no external account |
docker-sbx |
microVMs (Local microVMs with Docker Sandboxes) | Local |
DEFAULT_HOST_PROVIDER selects the provider for POST /hosts (default
exe). Set the matching provider variables below. The image contains
all provider extras.
The docker provider runs each sandbox as a local container with sshd,
so you can try drukbox with no cloud account or API token. The published
image includes the Docker CLI. Set DEFAULT_HOST_PROVIDER=docker,
TAILSCALE_ENABLED=false, and UVICORN_HOST=127.0.0.1 in drukbox.env,
then run:
# Linux: match the host socket's group. macOS: use group 0 (see below).
docker run --rm --network host \
--group-add "$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo 0)" \
--mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \
--env-file drukbox.env \
ghcr.io/czpython/drukbox:latestOn macOS, use --group-add 0 instead: Docker Desktop mounts the socket into
the container as root:root mode 0660, so only group 0 grants access — the
host socket's own gid is irrelevant.
Host networking is required because Docker sandboxes publish SSH on the
host's 127.0.0.1. The loopback Uvicorn binding makes the API reachable
only from that host. Do not combine this mode with the generic
-p 8780:8780 invocation above. On macOS, if sandbox SSH is
unreachable, enable host networking in Docker Desktop's settings.
The sandbox image (DOCKER_DEFAULT_IMAGE, default
ghcr.io/czpython/drukbox/sandbox:latest) is pulled on first provision.
To customize it, build images/local/ and point
DOCKER_DEFAULT_IMAGE at your tag.
Containers publish sshd on a random 127.0.0.1 port and are reachable
only from the host that runs drukbox; the per-host key is the auth
boundary. Tailscale is not supported — a local container has no path
onto the tailnet, so docker hosts stay local under a tailnet-mode
service: no join, no internal_ssh_host, the published port is the only
path. One drukbox can serve tailnet VMs and local containers side by
side.
This provider is for local development and demos, not production: it talks to the host's Docker daemon, and granting drukbox access to that socket is host-root-equivalent. Do not expose a docker-backed drukbox to untrusted callers.
Janitor and pool one-off containers using the Docker provider need the
same socket mount and socket-GID supplemental group. DOCKER_HOST remains
available when the daemon is remote or rootless instead of exposed through
/var/run/docker.sock.
The docker-sbx provider runs each sandbox as a
Docker Sandboxes microVM. Each
microVM has its own kernel, its own filesystem, and its own Docker
daemon. The sandboxd network policy controls the egress. This provider
is local to the drukbox machine, the same as the docker provider. It
does not support Tailscale.
Prepare the host fully before drukbox starts. drukbox only connects to the host:
- Install Docker Engine and
docker-sbx. Ubuntu 24.04+ with KVM is necessary:/dev/kvmmust exist, and the service user must be in thekvmgroup. - Sign in one time with
sbx login. Headless hosts use a device-code flow. - Start the daemon:
sbx daemon start -d --policy balanced.
Docker documents sbx as a tool for the daemon owner's own user on the
host. Thus the simplest deployment runs drukbox directly on the host, as
the same user:
uv run uvicorn api.app:app --host 127.0.0.1 --port 8780In this mode, no mounts and no extra variables are necessary. The CLI finds the daemon socket automatically.
drukbox can also run as a container adjacent to the daemon. Docker does
not document this mode; drukbox uses the CLI's own daemon-endpoint
variable, DOCKER_SANDBOXES_API. Mount the daemon socket, the sbx
binary of the host (then the CLI version and the daemon version always
agree), the CLI auth store, and the workspace root:
docker run --rm --network host \
--mount type=bind,src=$HOME/.local/state/sandboxes/sandboxes/sandboxd/sandboxd.sock,dst=/run/sandboxd.sock \
--mount type=bind,src=$(command -v sbx),dst=/usr/local/bin/sbx,readonly \
--mount type=bind,src=$HOME/.config/com.docker.sandboxes,dst=/root/.config/com.docker.sandboxes,readonly \
--mount type=bind,src=$HOME/.drukbox/sbx-workspaces,dst=$HOME/.drukbox/sbx-workspaces \
--env DOCKER_SANDBOXES_API=unix:///run/sandboxd.sock \
--env DOCKER_SBX_WORKSPACE_ROOT=$HOME/.drukbox/sbx-workspaces \
--env-file drukbox.env \
ghcr.io/czpython/drukbox:latestThe daemon reads workspace paths on its own filesystem. Thus the workspace mount must have the same path on the host and in the container. The janitor and pool containers need the same mounts and variables.
Callers reach the sandboxes through
the SSH gateway; the provider requires it. The key
for each host is the auth boundary. Sandboxes have no SERVICE_LABEL
tag, because sbx create has no label option.
The template image (DOCKER_SBX_DEFAULT_IMAGE, default
ghcr.io/czpython/drukbox/sbx-sandbox:latest) must start sshd without
environment variables. sbx create sends none. drukbox injects the key
for each host through the exec channel after the start. Build
images/sbx/ to change the template. The
images/local/ entrypoint needs boot-time environment variables and
cannot start as a sandbox template.
The daemon has its own image store and does not read local Docker images. It pulls unknown template names from a registry. For a local template, load the image into the daemon:
docker build -t drukbox/sbx-sandbox:latest images/sbx/
docker save drukbox/sbx-sandbox:latest -o /tmp/sbx-sandbox.tar
sbx template load /tmp/sbx-sandbox.tarA sandbox creation takes approximately 20 seconds with a warm template
cache, and more than 30 seconds at the first pull. Thus a warm pool
(POOL_SIZES) is useful. Each sandbox gets the explicit
DOCKER_SBX_CPUS and DOCKER_SBX_MEMORY sizes. Without them,
the daemon gives one sandbox all host CPUs and half of the host memory.
The gateway gives remote callers SSH access to the hosts of gateway
providers such as docker-sbx, whose sandboxes have no dialable sshd
of their own. Callers connect with normal SSH:
ssh -p 2222 -i <private-key> <host-name>@<gateway-address>The gateway authenticates the key against the host's stored public key,
and the username must name the same host. It then opens a session
through the provider — sbx exec for docker-sbx. The daemon stops an
idle sandbox; a connection through the gateway wakes it (approximately
6 seconds) and keeps it awake while connected. The first data can
therefore come after a short delay.
The gateway serves an interactive shell and command execution. It refuses SFTP, scp, and port forwarding.
The gateway is a requirement for gateway providers: POST /hosts for
docker-sbx fails without GATEWAY_SSH_HOST. Set it to the address
callers use. The response then carries the gateway coordinates:
external_ssh_host is the gateway, external_ssh_port is the gateway
port, and ssh_username is the host name. known_hosts carries the
gateway's host key. Hosts of the other providers are not affected.
Run the gateway on the machine that runs sandboxd, as the same user, and run the migrations first:
uv run python -m gateway.serverA systemd unit follows the same pattern as the API service. The gateway
reads the same drukbox.env and connects to the same database.
| Variable | Default | Purpose |
|---|---|---|
GATEWAY_SSH_HOST |
— (required) | Address of the gateway. Gateway-provider hosts advertise it; creation fails without it. |
GATEWAY_SSH_PORT |
2222 |
Port the gateway listens on and advertises. |
GATEWAY_BIND_HOST |
0.0.0.0 |
Interface the gateway server binds. |
GATEWAY_HOST_KEY_PATH |
~/.drukbox/gateway_host_key |
Private host key. The server makes one at start when the file does not exist. |
TAILSCALE_ENABLED=false (default): callers reach sandboxes over the
provider's public path. On AWS this means per-VM keypairs and the
managed security group — see
Networking.
TAILSCALE_ENABLED=true: sandboxes join your tailnet at boot and
callers connect over the overlay. Requires a Tailscale OAuth client
with auth-key write scope, and tailnet ACLs that (a) own the tags in
TAILSCALE_AUTH_TAGS and (b) permit tailscaled-SSH to the tagged
nodes.
AWS credentials come from the SDK's default chain (instance profile,
~/.aws, or env) — drukbox never plumbs them through its own
settings. The policy needs ec2:RunInstances,
ec2:TerminateInstances, ec2:DescribeInstances, ec2:CreateTags,
sts:GetCallerIdentity, plus — with Tailscale off —
ec2:ImportKeyPair, ec2:DeleteKeyPair, ec2:CreateSecurityGroup,
ec2:DescribeSecurityGroups, ec2:AuthorizeSecurityGroupIngress,
ec2:DescribeSubnets (when AWS_SUBNET_ID is set), and
ssm:GetParameter when AWS_DEFAULT_IMAGE is an SSM path.
Drukbox tags everything it creates with managed-by=<SERVICE_LABEL>,
so write permissions can be tag-scoped.
curl -fsS -H "Authorization: Bearer $TOKEN" http://localhost:8780/doctor/doctor runs one read-only probe per dependency (database, active
provider, Tailscale when enabled) and reports per-check status,
latency, and a remediation hint on failures. It always returns 200 —
health is the ok field. GET /healthz is the unauthenticated
liveness probe.
For a full end-to-end check, run the black-box suite against the deployment (it provisions and destroys a real host — disposable infrastructure only):
SERVICE_URL=http://localhost:8780 SERVICE_TOKEN=... npm --prefix api-tests testCore, required:
| Variable | Purpose |
|---|---|
DATABASE_URL |
Async SQLAlchemy URL. |
SERVICE_TOKENS |
Comma-separated bearer tokens accepted from trusted callers. |
Core, optional:
| Variable | Default | Purpose |
|---|---|---|
DEFAULT_HOST_PROVIDER |
exe |
Provider used when callers don't specify one. |
SERVICE_LABEL |
drukbox |
Label stamped onto provider resources (VM tags, SG tags). |
UVICORN_HOST |
0.0.0.0 |
API bind address. Set 127.0.0.1 to restrict to loopback. |
PROVISIONING_GRACE_SECONDS |
600 |
Safety TTL on in-flight hosts so the janitor reaps row + VM if the client disconnects mid-provision. Must exceed the worst-case provision duration. |
LEASE_DEFAULT_TTL |
86400 |
Lease TTL in seconds for hosts created without an explicit expires_at, and the extension applied by an empty POST /hosts/{id}/renew. An explicit expires_at: null at create time opts out of expiry entirely. |
IDEMPOTENCY_KEY_TTL_HOURS |
24 |
Retention period for successful Idempotency-Key mappings. |
POOL_SIZES |
{} |
Warm hosts to keep ready per provider, as JSON (e.g. {"exe": 2, "hetzner": 1}). Overrides POOL_SIZE for the providers it names. |
POOL_SIZE |
0 |
Warm hosts to keep ready for the default provider. 0 disables its pool. |
POOL_HOST_MAX_AGE_HOURS |
4 |
Max age before the janitor reaps an unclaimed pool host. |
POOL_MAX_CREATES_PER_TICK |
2 |
Upper bound on pool provisions per tick, across all providers; caps over-provision blast radius when ticks overlap. |
Tailscale (required when TAILSCALE_ENABLED=true):
| Variable | Default | Purpose |
|---|---|---|
TAILSCALE_ENABLED |
false |
Provision hosts onto a tailnet. |
TAILSCALE_TAILNET |
— | Tailnet DNS suffix for sandbox MagicDNS hostnames. |
TAILSCALE_AUTH_TAGS |
— | Comma-separated tags applied to minted auth keys. |
TAILSCALE_OAUTH_CLIENT_ID |
— | OAuth client ID. |
TAILSCALE_OAUTH_CLIENT_SECRET |
— | OAuth client secret. |
TAILSCALE_API_TIMEOUT |
30.0 |
Timeout for Tailscale API calls. |
DEVICE_DISCOVERY_TIMEOUT_SECONDS |
180.0 |
How long provisioning waits for a sandbox to appear in the tailnet. |
exe.dev provider:
| Variable | Default | Purpose |
|---|---|---|
EXE_API_TOKEN |
— (required) | Bearer token for the exe.dev exec API. |
EXE_DEFAULT_IMAGE |
— (required) | Image used when the caller omits image. |
EXE_API_URL |
https://exe.dev |
API base URL. |
EXE_API_TIMEOUT |
30.0 |
Timeout for exe.dev API calls. |
EXE_BOOTSTRAP_SSH_TIMEOUT_SECONDS |
30.0 |
ssh-keyscan retry budget for a fresh exe.dev sandbox. |
EXE_SSH_USERNAME |
exedev |
In-VM user callers SSH as. |
AWS provider:
| Variable | Default | Purpose |
|---|---|---|
AWS_REGION |
— (required) | Region for the EC2 client and launches. |
AWS_DEFAULT_IMAGE |
— (required) | AMI id or SSM parameter path used when the caller omits image. |
AWS_INSTANCE_TYPE |
t3.medium |
EC2 instance type when the caller omits instance_type. |
AWS_ROOT_GB |
100 |
Root EBS volume size (gp3, encrypted) when the caller omits disk_gb. |
AWS_SUBNET_ID |
— | Optional subnet; default VPC's otherwise. |
AWS_SECURITY_GROUP_ID |
— | Pre-existing SG; unset → drukbox manages drukbox-managed. |
AWS_SSH_CIDRS |
— | SSH ingress CIDRs. Authoritative when set; unset → detected egress /32, falling back to 0.0.0.0/0. |
AWS_INSTANCE_PROFILE |
— | Optional IAM instance profile attached to sandboxes. |
AWS_BOOTSTRAP_SSH_TIMEOUT_SECONDS |
120.0 |
ssh-keyscan retry budget for a fresh EC2 instance. |
AWS_SSH_USERNAME |
ubuntu |
In-VM user callers SSH as. |
Hetzner provider:
| Variable | Default | Purpose |
|---|---|---|
HETZNER_API_TOKEN |
— (required) | Bearer token for the Hetzner Cloud API. |
HETZNER_LOCATION |
— (required) | Location for launches, e.g. nbg1, fsn1, hel1, ash. |
HETZNER_DEFAULT_IMAGE |
ubuntu-24.04 |
Image name/id used when the caller omits image. |
HETZNER_SERVER_TYPE |
cx23 |
Server type when the caller omits instance_type, e.g. cx23, cx33. Hetzner retires older generations (e.g. cx22); a deprecated type fails provisioning with a 422. |
HETZNER_API_TIMEOUT |
30.0 |
Timeout for Hetzner API calls. |
HETZNER_BOOTSTRAP_SSH_TIMEOUT_SECONDS |
120.0 |
ssh-keyscan retry budget for a fresh server. |
HETZNER_SSH_USERNAME |
root |
In-VM user callers SSH as. |
A fresh Hetzner server has no firewall — port 22 is open and SSH is key-only. Drukbox mints a per-VM ed25519 key in both networking modes; there is no security-group or ingress-CIDR configuration to manage.
Exoscale provider:
| Variable | Default | Purpose |
|---|---|---|
EXOSCALE_API_KEY |
— (required) | Exoscale API key ID. |
EXOSCALE_API_SECRET |
— (required) | Exoscale API secret used to sign requests. |
EXOSCALE_ZONE |
— (required) | Zone for launches, e.g. ch-gva-2, de-fra-1. |
EXOSCALE_DEFAULT_IMAGE |
Linux Ubuntu 24.04 LTS 64-bit |
Template used when the caller omits image. |
EXOSCALE_INSTANCE_TYPE |
standard.medium |
Instance type when the caller omits instance_type. |
EXOSCALE_DISK_GB |
50 |
Root disk size in GB when the caller omits disk_gb. |
EXOSCALE_API_TIMEOUT |
30.0 |
Timeout for Exoscale API calls. |
EXOSCALE_BOOTSTRAP_SSH_TIMEOUT_SECONDS |
120.0 |
ssh-keyscan retry budget for a fresh instance. |
EXOSCALE_SSH_USERNAME |
ubuntu |
In-VM user callers SSH as. Exoscale Ubuntu templates default to ubuntu. |
The API key's IAM role must allow the compute operations list-templates and
list-instance-types in addition to the instance and SSH-key operations:
instance creation resolves the configured template and instance-type names to
IDs through those list calls.
Docker provider:
| Variable | Default | Purpose |
|---|---|---|
DOCKER_DEFAULT_IMAGE |
ghcr.io/czpython/drukbox/sandbox:latest |
Sandbox image with sshd; auto-pulled. Build images/local/Dockerfile to customize. |
DOCKER_SSH_USERNAME |
root |
In-container user callers SSH as. |
DOCKER_BOOTSTRAP_SSH_TIMEOUT_SECONDS |
30.0 |
ssh-keyscan retry budget for a fresh container. |
The published image includes the Docker CLI. Mount the local daemon socket
with its supplemental group on Linux, or use DOCKER_HOST for a remote or
rootless daemon. Drukbox mints a per-VM ed25519 key and publishes sshd on a
random 127.0.0.1 port. See
Local sandboxes with Docker for the
container command and the trust caveat.
Docker Sandboxes provider:
| Variable | Default | Purpose |
|---|---|---|
DOCKER_SBX_DEFAULT_IMAGE |
ghcr.io/czpython/drukbox/sbx-sandbox:latest |
Template image that contains sshd and starts without environment variables. Build images/sbx/Dockerfile to change it. |
DOCKER_SBX_SSH_USERNAME |
root |
User in the sandbox for caller SSH access. |
DOCKER_SBX_BOOTSTRAP_SSH_TIMEOUT_SECONDS |
30.0 |
Time limit for the ssh-keyscan tries on a new sandbox. |
DOCKER_SBX_CPUS |
2 |
Number of CPUs for each sandbox. |
DOCKER_SBX_MEMORY |
2g |
Memory for each sandbox, in binary units. |
DOCKER_SBX_WORKSPACE_ROOT |
~/.drukbox/sbx-workspaces |
Directory with one temporary workspace for each sandbox. The path must be the same for drukbox and for the daemon. |
The published image does not contain the sbx CLI. Mount the binary and
the auth store of the host, as
Local microVMs with Docker Sandboxes
shows. Set DOCKER_SANDBOXES_API to the mounted daemon socket.