iii Cloud deployments
Theiii cloud subcommand group will manage hosted iii deployments. See CLI for the
command surface as it stabilizes.
iii’s cloud will be available soon.
Pre-built images on Docker Hub
iii publishes the engine image toiiidev/iii. You do not
need to compile anything to run the engine.
linux/amd64 and linux/arm64), built on
gcr.io/distroless/cc-debian12:nonroot, and runs as UID 65532. Its entrypoint is /app/iii and
its default command is --config /app/config.yaml, so you only supply a config.yaml. There is no
shell, package manager, or other userland in the image.
Pin an immutable tag or digest for production (for example
iiidev/iii@sha256:...) so a deploy is
reproducible. latest changes when a new version ships.Deploy with Docker
What gets deployed
The generated image packages the engine and yourconfig.yaml, not your application code. Your
application is made of workers, and for deployment what matters is where each worker runs:
- Workers configured in
config.yamlrun in-process in the engine (for exampleiii-http,iii-state,iii-queue,iii-cron,iii-stream,iii-observability). Enable them by listing them inconfig.yaml. - Workers you run yourself (Node, Python, Rust) connect to the engine over WebSocket as separate
processes. The engine image is distroless and carries no language runtime, so it does not host
them. Deploy each as its own container and point
III_URLat the engine (ws://iii:49134on the compose network). See Creating Workers / Connecting to the engine.
iii project generate-docker does not scaffold a sample app. A bare iii project init produces a
minimal config.yaml with no functions or endpoints. To start from working code, scaffold a template
first (iii project init my-app --template quickstart), then generate the Docker assets. See the
Quickstart for the sample workers.
Generate the assets
Generate the Docker assets with the CLI and bring them up with Compose. Useiii project init --docker for a fresh project, or iii project generate-docker to add Docker
assets to an existing one:
Dockerfile, docker-compose.yml, and .env.
Re-running the generator does not overwrite existing files, so edits you make to the templates stick.
The generated Dockerfile builds against iiidev/iii:latest and copies your config.yaml into the
image:
| Port | Surface |
|---|---|
| 49134 | SDK WebSocket (worker connections) |
| 3111 | REST API |
| 3112 | Stream API |
| 49135 | Browser SDK / RBAC listener (when enabled in config) |
Port
9464 was the Prometheus metrics endpoint in older releases and still appears in the image’s
EXPOSE list and the generated assets. The current engine has no Prometheus exporter, so nothing
listens on it. Leave it unmapped and export metrics over OTLP. See
Observability.Mount the config and data
Mountconfig.yaml read-only, and put every file-based file_path on a named volume so data
survives container replacement:
file_path: /data/state_store.db. The image runs as
UID 65532, so the volume must be writable by that user. Named volumes handle this; for a bind
mount, set the host directory’s ownership.
Build from source
Use this only when you are building a custom engine or running on a platform without the pre-built image. Build the binary from the repository root:target/release/iii. Run it against a config:
/app/iii:
Build for the target platform. The base image is Debian-based (glibc), so a musl binary will not
run on it, and an
arm64 binary will not run on an amd64 host. Cross-compile with the matching
target (for example --target x86_64-unknown-linux-gnu) or build on the target architecture.Configure workers and adapters
The engine is stateless. State is held in workers, and each stateful worker selects a storage adapter inconfig.yaml. The defaults are in-memory, tuned for local development, so choosing
durable adapters is the main production step.
| Worker | Stores | Adapters | Default |
|---|---|---|---|
iii-state | Key/value state | kv (in_memory / file_based), redis, bridge | kv in-memory |
iii-stream | Realtime streams | kv (in_memory / file_based), redis, bridge | kv in-memory |
iii-queue | Background jobs / topics | builtin (in_memory / file_based), rabbitmq, redis (publish-only) | builtin in-memory |
iii-cron | Scheduled-task bookkeeping | kv (in_memory / file_based) | kv in-memory |
configuration | Runtime config entries | fs (file-based, defaults to ./data/configuration) | file-based |
iii-pubsub | Pub/sub fan-out | local, redis | local |
iii-http | Nothing (request routing) | n/a | n/a |
iii-observability | Traces / metrics / logs | memory or otlp exporters | memory |
kv-backed adapter runs in-memory, the engine logs DO NOT USE IN_MEMORY STORE_METHOD IN PRODUCTION - DATA WILL BE LOST ON SHUTDOWN on boot. Treat that as a release blocker. The
iii-queue builtin adapter is in-memory by default, so jobs are lost on restart until you make it
file-based or move it to RabbitMQ.
For each worker’s full config fields, see the
Worker manifest reference and the
Engine configuration page.
Make storage durable with file_based
File-based storage is the simplest durable option: no extra services, only a writable path on a mounted volume. Setstore_method: file_based and a file_path for each kv-backed worker.
kv adapter flushes on a save_interval_ms cadence (default 5000 ms). A hard kill
between flushes can drop the most recent writes; use Redis when you need stronger durability.
The storage adapter is restart-tier and persisted. After first boot,
iii-state writes its live
settings to ./data/configuration/iii-state.yaml (the default fs configuration adapter), and the
config.yaml adapter block becomes seed-only. Editing the adapter in config.yaml after that has
no effect. The persisted entry wins on the next boot. To change an adapter later, edit the
persisted file or call configuration::set. On a fresh deploy with an empty ./data, the
config.yaml block is the seed.Scale out with Redis and RabbitMQ
File-based storage is single-instance. To run more than one engine replica, or for real-time fan-out across processes, move stateful workers to an external backend. This step is optional. A single replica on file-based storage is a valid production setup. Match the backend to the workload:iii-state,iii-stream,iii-pubsub→ Redis, for shared key/value, stream fan-out, and pub/sub.iii-queue→ RabbitMQ, for durable cross-instance delivery with retries and DLQs. The queueredisadapter is publish-only for named queues (no named-queue consumption, retries, or DLQs), so it does not replacerabbitmqor the single-instancebuiltinadapter.
redis and rabbitmq services in the generated docker-compose.yml and add a
depends_on so the engine waits for the backends to be healthy:
Reference each backend by its compose service name (
redis://redis:6379,
amqp://...@rabbitmq:5672), not localhost. Inside the engine container, localhost is the
engine. Keep credentials in environment variables; ${VAR:default} expands in config.yaml, and
the generated .env already carries RABBITMQ_USER / RABBITMQ_PASS. Switching a stateful worker
to a remote adapter after first boot requires updating the persisted configuration entry, not only
the config.yaml block (see the note above).Adjust the worker manifest for production
A worker’siii.worker.yaml describes how the engine provisions and starts it. The development
defaults favor fast iteration; production wants the opposite. Adjust the manifest before you ship:
scripts.start: drop watch mode so a code change does not restart the process and a crash is a real signal:node dist/index.js, notnpx tsx watch src/index.ts.scripts.install: install from a lockfile without dev dependencies:npm ci --omit=dev.env: inject production configuration. Keep secrets out of the file and supply them from the environment. The keysIII_URLandIII_ENGINE_URLare ignored; the engine sets the connection URL.runtime.base_image: pin a specific base image so builds are reproducible.resources: set explicitcpusandmemoryfor the worker’s sandbox (defaults are 2 vCPU / 2048 MiB, capped at 4 / 4096).
Deploy a multi-worker product with Compose
A real product composes several workers: the engine, its adapters (Redis, RabbitMQ), your own workers, and registry workers. The iii registry at workers.iii.dev hosts the installable workers:database (PostgreSQL/MySQL/SQLite), storage (S3/GCS/R2/local),
harness, iii-sandbox, and the rest, each added with iii worker add <name>.
Workers reach the engine in one of two ways, and which one you use decides how it deploys:
- Connect over WebSocket: your own workers (and any worker you choose to run yourself) run as
their own container and point
III_URLat the engine service. This is the portable, Compose-native pattern. - Engine-managed (added with
iii worker add): the engine runs them itself in libkrun micro-VMs, including registry workers likedatabase,storage,harness, andiii-sandbox. They run wherever the engine runs, so they ship inside the engine image, which must include theiii workertooling (theiii-workerbinary), and the host needs hardware virtualization (/dev/kvmon Linux, Apple Silicon on macOS).
Engine, adapters, and your own workers
The engine runs the in-process workers from itsconfig.yaml (iii-http, iii-state,
iii-queue, …); each of your own workers runs as its own service and connects with
III_URL=ws://iii:49134 (the engine’s service name on the Compose network).
config.yaml (redis://redis:6379,
amqp://...@rabbitmq:5672); see
Scale out with Redis and RabbitMQ. Add as many of your own
workers as the product needs. Each is another service that connects over III_URL.
Engine-managed workers (micro-VMs)
Workers added withiii worker add (registry workers like database, storage, and harness, as
well as iii-sandbox) are run by the engine itself in libkrun micro-VMs. The engine records them
in config.yaml, starts each one on boot, and passes it its own config (connection string,
credentials):
iii worker, the same tool you use to add and manage workers. It is its
own binary (iii-worker) that ships with the install alongside the iii engine and embeds the
micro-VM firmware; it is normally invoked as iii worker ..., but the engine also calls it directly.
This is not a separate language runtime. Running these workers in a container takes more than the
stock image:
- The stock
iiidev/iiiimage cannot run them. It is distroless: it ships only theiiiengine binary, has no shell, and lacks the shared librariesiii-workerneeds (for examplelibcap-ng.so.0). Copyingiii-workerinto aFROM iiidev/iiibuild is not enough. It fails to load. Build the engine image on a glibc base (for exampledebian-slim) that hasiii-worker’s library dependencies, and include bothiiiandiii-worker. - Bake the workers at build time. You cannot
RUN iii worker addin aFROM iiidev/iiibuild (no shell), so install the workers from a shell-capable build stage (or on a host) and carry the artifacts (iii.lockand~/.iii/workers) into the final image. - Provide the KVM device. Micro-VMs need hardware virtualization:
/dev/kvmon a Linux host, or Apple Silicon when running the engine directly on macOS.
Docker Desktop on macOS does not expose
/dev/kvm to its Linux VM, so managed-worker micro-VMs do
not boot inside Compose on a Mac. Run them on a Linux host with KVM, or run the engine directly on
the macOS host (Apple Silicon), where the virtualization is available.Each registry worker has its own config schema (database connection, storage backend, credentials).
Look it up on the worker’s page at workers.iii.dev, and supply secrets
from the environment.
Hardening
Bind interfaces deliberately
Inside a container, bind0.0.0.0 so published ports are reachable, and control exposure at the
Docker, orchestrator, and reverse-proxy layers. On a bare host, bind 127.0.0.1 and let a local
reverse proxy reach the engine. Do not expose 49134, 3111, or 3112 directly to the internet.
Restrict CORS
The development default allows all origins. Restrict it to the origins that call your API:Keep secrets in the environment
Use${VAR} / ${VAR:default} placeholders in config.yaml and supply the values through the
container environment or your orchestrator’s secret store. The generated .env (which holds the
RabbitMQ password) is git-ignored; do not bake secrets into the image.
RBAC
Workers connect over WebSocket. The main engine port (49134) is trusted, internal traffic with no
access control. To accept worker connections from outside your trust boundary, run a second
iii-worker-manager listener with role-based access control and expose only that port.
expose_functionsis the allowlist of function IDs reachable through this listener, by wildcardmatch("...")pattern or bymetadatafilter. A function that matches no filter is denied; a caller invoking it getsfunction '<id>' not allowed (add to rbac.expose_functions).auth_function_idruns once per WebSocket upgrade. It receives the request headers, query params, and client IP, and returns per-session allow/forbid lists plus a context object. Throwing rejects the connection.middleware_function_id(a sibling ofrbac, at theconfiglevel) runs before every invocation for audit logging, rate limiting, or payload enrichment.
Expose only the RBAC listener externally. Keep the main engine port (
49134) and stream port
(3112) on a private network, enforced with firewall rules or network policies. Channels work
through the RBAC listener at /ws/channels/{channel_id} on the same port.Reverse proxies
The engine does not terminate TLS. Put a reverse proxy in front of it to handle TLS and route the transport surfaces. Each surface is a separate port, and each WebSocket surface is served at the root path of its port:| Surface | Internal port | Path on that port |
|---|---|---|
| REST API | 3111 | your registered routes, at root |
| Stream API (WS) | 3112 | / |
| Worker connections (WS) | 49134 | / (channels at /ws/channels/{id}) |
| RBAC listener (WS) | 49135 | / (channels at /ws/channels/{id}) |
Subdomain per surface
Giving each surface its own hostname needs no path rewriting, and the WebSocket roots line up.Single host with path prefixes
To serve everything from one hostname, route by path and strip the prefix so each WebSocket surface is reached at its root. With nginx, a trailing slash onproxy_pass strips the matched prefix:
handle_path:
Long-lived WebSocket connections need a generous proxy read timeout. The nginx default (60 s)
drops idle worker and stream sockets; set a high
proxy_read_timeout. Caddy keeps streaming
connections open by default.Health checks
The engine has no HTTP health endpoint, and the image has no shell or networking utility, soHEALTHCHECK directives that shell out (for example CMD-SHELL with nc or curl) do not work.
Probe from outside the container:
- A TCP check against the REST port (
3111) is a reliable liveness signal. In Kubernetes, use atcpSocketprobe. - A reverse proxy can run upstream health checks against the REST port.
- For a readiness signal that reflects worker availability, register an HTTP-triggered function that
returns
200and probe that route.
Observability
Theiii-observability worker collects traces, metrics, and logs through one of two exporters:
memory(default): keeps recent observability data in the engine, queryable through its observability functions. Bounded bymemory_max_spansand retention settings.otlp: forwards traces, metrics, and logs to an OTLP collector. Use this in production.
4317); logs always export over OTLP/HTTP to
<endpoint>/v1/logs (collector port 4318). So a single default-protocol endpoint can’t satisfy
all three: pointed at 4317 it delivers traces and metrics but drops logs.
To send all three signals to one endpoint, switch the engine to HTTP and use the collector’s HTTP
port (4318):
endpoint: http://otel-collector:4317 (gRPC) and accept that logs need the
HTTP path. Metrics export on a 60-second interval. TLS is enabled automatically for https://
endpoints.
metrics_exporter accepts only memory or otlp. Setting prometheus (removed in a prior
release) stops the engine from starting: unknown variant 'prometheus', expected 'memory' or 'otlp'. There is no Prometheus scrape endpoint on port 9464.Deployment checklist
Image and build- Pinned an immutable image tag or digest, not
latest. - If building from source, compiled for the target arch and libc and placed the binary at
/app/iii.
- No
DO NOT USE IN_MEMORYwarnings in the boot logs. -
iii-state,iii-stream, andiii-cronusefile_basedorredis;iii-queueusesfile_basedbuiltinorrabbitmq. - Every
file_pathresolves to a mounted volume writable by UID65532. - Adapter changes account for the persisted configuration entry (a fresh deploy seeds from
config.yaml; an existing one reads./data/configuration). - For multiple replicas, all stateful workers use an external backend referenced by service name, with credentials from the environment.
- Worker manifests use production
start/installscripts (no watch mode, lockfile installs). - Worker
envandresourcesset; secrets injected from the environment. - Your own workers run as their own services pointing
III_URLat the engine. - Engine-managed workers (
database,storage,harness,iii-sandbox, …) run on a glibc-based engine image that bundlesiii-workerand its libraries (not the distroless stock image), with/dev/kvm(Linux host, not inside Docker Desktop on macOS), and each is listed inconfig.yamlwith its own config/secrets.
- Engine binds
0.0.0.0in containers,127.0.0.1on bare hosts. - Only required ports are published;
49134and3112stay private. - CORS restricted to real origins.
- External worker connections go through an RBAC listener with an auth function.
- TLS terminated at a reverse proxy; WebSocket upgrade headers and long read timeouts set.
- External health checks configured (TCP
3111ortcpSocket), not in-container shell probes. - Observability exporter set to
otlpwith a collector endpoint, ormemoryaccepted for scope. - Restart policy set (
restart: unless-stoppedor the orchestrator equivalent).