Skip to main content
This page covers running the iii engine and its workers in a production environment. For local development, see the Quickstart and the Engine page.

iii Cloud deployments

The iii cloud subcommand group will manage hosted iii deployments.
iii’s cloud will be available soon.

Platform guides

The below guide covers general deployment using docker. We also have step-by-step deploy guides for common hosts. These guides build on the Docker patterns below.

Pre-built images on Docker Hub

iii publishes the engine image to iiidev/iii. You do not need to compile anything to run the engine.
The image is multi-arch (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 your config.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.yaml run in-process in the engine (for example iii-http, iii-state, iii-queue, iii-cron, iii-stream, iii-observability). Enable them by listing them in config.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_URL at the engine (ws://iii:49134 on 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. Use iii project init --docker for a fresh project, or iii project generate-docker to add Docker assets to an existing one:
Both forms emit three files at the project root: 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:
Start the stack:
The transport ports are: The compose file ships commented-out Redis and RabbitMQ services that you uncomment when your workers need external adapters. See Scale out with Redis and RabbitMQ.
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

Mount config.yaml read-only, and put every file-based file_path on a named volume so data survives container replacement:
Point adapters at the mounted path, for example file_path: /data/state_store.db. Point the configuration worker at the volume too. It defaults to ./data/configuration, which resolves to /app/data/configuration (relative to the engine’s WORKDIR /app), not the /data volume, so on the non-root image it is the first thing to crash-loop with failed to create configuration directory './data/configuration': Permission denied:
The image runs as UID 65532, so /data must be writable by that user. The stock iiidev/iii image does not pre-create /data, so Docker mounts a fresh named volume as root:root and the engine cannot write to it. Use one of:
  • a bind mount to a host directory you chown 65532:65532, or
  • an engine image that creates /data (and /app/data) owned by 65532, after which a named volume inherits that 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:
This produces a single binary at target/release/iii. Run it against a config:
To ship your own binary on the official base image, copy it over the entrypoint at /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 in config.yaml. The defaults are in-memory, tuned for local development, so choosing durable adapters is the main production step. When a 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. Set store_method: file_based and a file_path for each kv-backed worker.
A file-based 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 queue redis adapter is publish-only for named queues (no named-queue consumption, retries, or DLQs), so it does not replace rabbitmq or the single-instance builtin adapter.
Uncomment the 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} in config.yaml is expanded by the engine process, so the variables must be in the engine container’s environment. The generated .env carries RABBITMQ_USER / RABBITMQ_PASS, but Compose only uses .env to substitute the compose file itself; it does not forward those into the container. Add env_file: .env (or an explicit environment: list) to the iii service, or the engine boots with Environment variable 'RABBITMQ_USER' not set. Switching a stateful worker to a remote adapter after first boot also requires updating the persisted configuration entry, not only the config.yaml block (see the note above).

Adjust the worker manifest for production

A worker’s iii.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, not npx 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. For engine-managed workers, the manifest keys III_URL and III_ENGINE_URL are ignored; the engine sets the connection URL. For workers you run yourself (Compose services, Fly apps, Railway services), set III_URL in the container environment and pass it to registerWorker(process.env.III_URL).
  • runtime.base_image: pin a specific base image so builds are reproducible.
  • resources: set explicit cpus and memory for the worker’s sandbox (defaults are 2 vCPU / 2048 MiB, capped at 4 / 4096).
See the Worker manifest reference for every field.

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_URL at the engine service. This is the portable, Compose-native pattern.
  • Engine-managed (added with iii worker add): the engine starts them from config.yaml. Workers with deploy: binary run as plain child processes; workers with deploy: bundle or deploy: image boot libkrun guests and need /dev/kvm.

Engine, adapters, and your own workers

The engine runs the in-process workers from its config.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).
The worker’s image is an ordinary language image that installs the SDK and runs your code. A minimal Node example:
Reference adapters by service name in 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.

Registry workers (engine-managed)

Workers added with iii worker add are started by the engine from config.yaml. How they run depends on the worker’s top-level deploy: field in its iii.worker.yaml on workers.iii.dev: Declare them in config.yaml alongside the in-process workers:
The iii-worker daemon (iii worker ...) ships with the install and is what the engine calls to start registry workers. Running binary registry workers in a container takes more than the stock distroless image:
  • The stock iiidev/iii image cannot run them. It is distroless: engine-only, no shell, and no libcap-ng.so.0 for iii-worker. Build on a glibc base (for example debian-slim) with both iii and iii-worker.
  • Bake workers at build time with RUN iii worker add <name> in a shell-capable build stage. Binary workers land in ~/.iii/workers/; bundle workers also need ~/.iii/rootfs/<lang> (large).
  • deploy: bundle / deploy: image workers need /dev/kvm. On hosts without it (Fly Machines, Railway containers, Docker Desktop on macOS), use deploy: binary workers or run libkrun workloads on a KVM-capable Linux host. On those hosts, iii-sandbox may register but guest execution (sandbox::run, OCI invocations) does not work.
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, bind 0.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.
How it gates access:
  • expose_functions is the allowlist of function IDs reachable through this listener, by wildcard match("...") pattern or by metadata filter. A function that matches no filter is denied; a caller invoking it gets function '<id>' not allowed (add to rbac.expose_functions).
  • auth_function_id runs 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 of rbac, at the config level) 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:

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 on proxy_pass strips the matched prefix:
The equivalent in Caddy, which handles WebSocket upgrades automatically and strips the matched prefix with handle_path:
These are example configurations. See the Caddy and Nginx documentation for full TLS, header, and timeout options.
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, so HEALTHCHECK 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 a tcpSocket probe.
  • 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 200 and probe that route.

Observability

The iii-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 by memory_max_spans and retention settings.
  • otlp: forwards traces, metrics, and logs to an OTLP collector. Use this in production.
Pick the port to match the protocol. This is the common mistake. Traces and metrics export over OTLP/gRPC by default (collector port 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):
Without that env var, use 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.
Configuration and persistence
  • No DO NOT USE IN_MEMORY warnings in the boot logs.
  • iii-state, iii-stream, and iii-cron use file_based or redis; iii-queue uses file_based builtin or rabbitmq.
  • Every file_path resolves to a mounted volume writable by UID 65532.
  • 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.
Workers
  • Worker manifests use production start / install scripts (no watch mode, lockfile installs).
  • Worker env and resources set; secrets injected from the environment.
  • Your own workers run as their own services pointing III_URL at the engine.
  • Engine-managed registry workers use a glibc-based engine image with iii-worker and libcap-ng0 (not the distroless stock image). /dev/kvm only when using deploy: bundle or deploy: image workers.
Networking and security
  • Engine binds 0.0.0.0 in containers, 127.0.0.1 on bare hosts.
  • Only required ports are published; 49134 and 3112 stay 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.
Operations
  • External health checks configured (TCP 3111 or tcpSocket), not in-container shell probes.
  • Observability exporter set to otlp with a collector endpoint, or memory accepted for scope.
  • Restart policy set (restart: unless-stopped or the orchestrator equivalent).