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. 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 to iiidev/iii. You do not need to compile anything to run the engine.
docker pull iiidev/iii:latest
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:
iii project generate-docker
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:
FROM iiidev/iii:latest

ENV III_EXECUTION_CONTEXT=docker

WORKDIR /app
COPY config.yaml /app/config.yaml

EXPOSE 49134 3111 3112 9464
ENTRYPOINT ["/app/iii"]
CMD ["--config", "/app/config.yaml"]
Start the stack:
docker compose up -d
The transport ports are:
PortSurface
49134SDK WebSocket (worker connections)
3111REST API
3112Stream API
49135Browser SDK / RBAC listener (when enabled in config)
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:
services:
  iii:
    build:
      context: .
    ports:
      - "49134:49134"
      - "3111:3111"
      - "3112:3112"
    volumes:
      - ./config.yaml:/app/config.yaml:ro
      - iii_data:/data
    environment:
      - RUST_LOG=info
      - III_EXECUTION_CONTEXT=docker
    restart: unless-stopped

volumes:
  iii_data:
Point adapters at the mounted path, for example 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:
cargo build --release
This produces a single binary at target/release/iii. Run it against a config:
./target/release/iii --config ./config.yaml
To ship your own binary on the official base image, copy it over the entrypoint at /app/iii:
FROM iiidev/iii:latest
COPY target/release/iii /app/iii
COPY config.yaml /app/config.yaml
ENTRYPOINT ["/app/iii"]
CMD ["--config", "/app/config.yaml"]
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.
WorkerStoresAdaptersDefault
iii-stateKey/value statekv (in_memory / file_based), redis, bridgekv in-memory
iii-streamRealtime streamskv (in_memory / file_based), redis, bridgekv in-memory
iii-queueBackground jobs / topicsbuiltin (in_memory / file_based), rabbitmq, redis (publish-only)builtin in-memory
iii-cronScheduled-task bookkeepingkv (in_memory / file_based)kv in-memory
configurationRuntime config entriesfs (file-based, defaults to ./data/configuration)file-based
iii-pubsubPub/sub fan-outlocal, redislocal
iii-httpNothing (request routing)n/an/a
iii-observabilityTraces / metrics / logsmemory or otlp exportersmemory
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.
workers:
  - name: iii-state
    config:
      adapter:
        name: kv
        config:
          store_method: file_based
          file_path: /data/state_store.db

  - name: iii-stream
    config:
      adapter:
        name: kv
        config:
          store_method: file_based
          file_path: /data/stream_store

  - name: iii-queue
    config:
      adapter:
        name: builtin
        config:
          store_method: file_based
          file_path: /data/queue_store

  - name: iii-cron
    config:
      adapter:
        name: kv
        config:
          store_method: file_based
          file_path: /data/cron_store
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.
workers:
  - name: iii-state
    config:
      adapter:
        name: redis
        config:
          redis_url: redis://redis:6379

  - name: iii-stream
    config:
      adapter:
        name: redis
        config:
          redis_url: redis://redis:6379

  - name: iii-pubsub
    config:
      adapter:
        name: redis
        config:
          redis_url: redis://redis:6379

  - name: iii-queue
    config:
      adapter:
        name: rabbitmq
        config:
          amqp_url: amqp://${RABBITMQ_USER}:${RABBITMQ_PASS}@rabbitmq:5672
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:
services:
  iii:
    # ...
    depends_on:
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  rabbitmq:
    image: rabbitmq:3-management-alpine
    environment:
      - RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
      - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASS}
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "check_running"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  redis_data:
  rabbitmq_data:
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’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. The keys III_URL and III_ENGINE_URL are ignored; the engine sets the connection 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 runs them itself in libkrun micro-VMs, including registry workers like database, storage, harness, and iii-sandbox. They run wherever the engine runs, so they ship inside the engine image, which must include the iii worker tooling (the iii-worker binary), and the host needs hardware virtualization (/dev/kvm on Linux, Apple Silicon on macOS).

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).
services:
  iii:
    image: iiidev/iii:latest
    volumes:
      - ./config.yaml:/app/config.yaml:ro
      - iii_data:/data
    ports:
      - "3111:3111"      # REST (front this with a reverse proxy in production)
    environment:
      - III_EXECUTION_CONTEXT=docker
    restart: unless-stopped

  # Your worker (your image, your code) connects to the engine over the network.
  app-worker:
    build: ./worker            # a Node/Python/Rust image that runs your worker
    environment:
      - III_URL=ws://iii:49134
    depends_on:
      - iii
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    volumes: [redis_data:/data]
    restart: unless-stopped

  rabbitmq:
    image: rabbitmq:3-management-alpine
    environment:
      - RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
      - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASS}
    volumes: [rabbitmq_data:/var/lib/rabbitmq]
    restart: unless-stopped

volumes:
  iii_data:
  redis_data:
  rabbitmq_data:
The worker’s image is an ordinary language image that installs the SDK and runs your code. A minimal Node example:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install          # depends on iii-sdk
COPY . ./
CMD ["node", "index.mjs"]
import { registerWorker } from "iii-sdk";
const worker = registerWorker(process.env.III_URL); // ws://iii:49134
// worker.registerFunction(...) / worker.registerTrigger(...)
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.

Engine-managed workers (micro-VMs)

Workers added with iii 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):
# config.yaml: managed workers listed alongside the in-process ones
workers:
  - name: database
    config:
      url: ${DATABASE_URL}             # e.g. postgres://user:pass@db:5432/app
  - name: storage
    config:
      backend: s3                      # see the worker's own config reference
Micro-VMs are managed by 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/iii image cannot run them. It is distroless: it ships only the iii engine binary, has no shell, and lacks the shared libraries iii-worker needs (for example libcap-ng.so.0). Copying iii-worker into a FROM iiidev/iii build is not enough. It fails to load. Build the engine image on a glibc base (for example debian-slim) that has iii-worker’s library dependencies, and include both iii and iii-worker.
  • Bake the workers at build time. You cannot RUN iii worker add in a FROM iiidev/iii build (no shell), so install the workers from a shell-capable build stage (or on a host) and carry the artifacts (iii.lock and ~/.iii/workers) into the final image.
  • Provide the KVM device. Micro-VMs need hardware virtualization: /dev/kvm on a Linux host, or Apple Silicon when running the engine directly on macOS.
services:
  iii:
    image: your-engine-image   # glibc base with iii + iii-worker + baked workers
    devices:
      - /dev/kvm               # required for micro-VMs
    volumes:
      - ./config.yaml:/app/config.yaml:ro
      - iii_data:/data
    restart: unless-stopped
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.
workers:
  - name: iii-http
    config:
      host: 0.0.0.0          # container; use 127.0.0.1 on a bare host
      port: 3111
      default_timeout: 30000
      concurrency_request_limit: 1024

Restrict CORS

The development default allows all origins. Restrict it to the origins that call your API:
workers:
  - name: iii-http
    config:
      cors:
        allowed_origins:
          - https://app.example.com
        allowed_methods:
          - GET
          - POST
          - PUT
          - DELETE
          - OPTIONS

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.
workers:
  - name: iii-worker-manager
    config:
      host: 0.0.0.0
      port: 49134            # main, trusted listener

  - name: iii-worker-manager
    config:
      host: 0.0.0.0
      port: 49135            # public RBAC listener
      rbac:
        auth_function_id: my-project::auth-function
        expose_functions:
          - match("api::*")
          - metadata:
              public: true
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:
SurfaceInternal portPath on that port
REST API3111your 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.
# REST API
server {
    listen 443 ssl;
    server_name api.example.com;
    location / {
        proxy_pass http://127.0.0.1:3111;
    }
}

# Worker / RBAC WebSocket
server {
    listen 443 ssl;
    server_name ws.example.com;
    location / {
        proxy_pass http://127.0.0.1:49135;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}

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:
server {
    listen 443 ssl;
    server_name example.com;

    location /ws/ {
        proxy_pass http://127.0.0.1:49134/;   # trailing slash strips "/ws/"
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }

    location /stream/ {
        proxy_pass http://127.0.0.1:3112/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }

    location / {
        proxy_pass http://127.0.0.1:3111;
    }
}
The equivalent in Caddy, which handles WebSocket upgrades automatically and strips the matched prefix with handle_path:
example.com {
    handle_path /ws/* {
        reverse_proxy 127.0.0.1:49134
    }
    handle_path /stream/* {
        reverse_proxy 127.0.0.1:3112
    }
    handle {
        reverse_proxy 127.0.0.1:3111
    }
}
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):
workers:
  - name: iii-observability
    config:
      enabled: true
      service_name: iii
      exporter: otlp
      endpoint: http://otel-collector:4318   # collector HTTP port
      metrics_enabled: true
      metrics_exporter: otlp
      logs_enabled: true
      logs_exporter: otlp
# in the engine service's environment (Compose): forces all signals onto HTTP/protobuf
environment:
  - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
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 workers (database, storage, harness, iii-sandbox, …) run on a glibc-based engine image that bundles iii-worker and its libraries (not the distroless stock image), with /dev/kvm (Linux host, not inside Docker Desktop on macOS), and each is listed in config.yaml with its own config/secrets.
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).