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.
Four remote providers are supported and verified end to end: exe
(exe.dev), aws (EC2), hetzner (Hetzner Cloud), and exoscale
(Exoscale). A fifth, docker,
runs sandboxes as local containers and needs no external account — see
Local sandboxes with Docker. DEFAULT_HOST_PROVIDER
selects which one serves POST /hosts (default exe). Set the matching
provider variables below. The image ships with all provider extras
installed.
The docker provider runs each sandbox as a local container with sshd,
so you can try drukbox with no cloud account or API token. It needs a
Docker-compatible runtime on the host (Docker Desktop, OrbStack, Colima):
export DEFAULT_HOST_PROVIDER=docker
export TAILSCALE_ENABLED=falseThe 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 POST /hosts fails fast if TAILSCALE_ENABLED=true.
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.
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 docker provider uses the host's configured Docker CLI, so the local
development path is to run drukbox from source on that host. The
published API image does not install a Docker CLI. If you containerize
this mode, build a custom image with a Docker CLI and mount or point it
at a daemon yourself (DOCKER_HOST, Docker Desktop, OrbStack, Colima);
that access is host-root-equivalent. Drukbox mints a per-VM ed25519 key
and publishes sshd on a random 127.0.0.1 port. See
Local sandboxes with Docker for setup and
the trust caveat.