- Deploying & Operating
- Run Ironflow Alongside Your App
Run Ironflow Alongside Your App
Ironflow has no in-process or library mode. It is always a separate process that
speaks HTTP — there is no version of it you link into your own binary. (In Go the
mechanism is explicit: the engine lives under internal/, which the toolchain forbids
external modules from importing.)
So run Ironflow as a companion process on the same host and talk to it over HTTP — over loopback under systemd, or over the project network under Docker Compose. This page covers that setup: two independently-managed processes, no supervision code in your application.
┌──────────────── one host ────────────────┐│ ││ your-app ironflow serve ││ ┌──────────────┐ ┌──────────────┐ ││ │ SDK or HTTP │─────▶│ HTTP :9123 │ ││ │ │◀─────│ embedded NATS│ ││ └──────────────┘ 127. │ SQLite │ ││ 0.0.1 └──────┬───────┘ ││ │ ││ .ironflow/ ││ (db, blobs, nats) │└──────────────────────────────────────────┘Your app can be any language. Go and TypeScript have full worker SDKs; Python has a client-only SDK; everything else talks to the REST API directly. Step 3 has a tab for each.
Both processes are started by the same supervisor — systemd, a Compose file, or your init system of choice. Neither one spawns the other.
When not to use this
Ironflow is stateful: it owns a SQLite database, a blob directory, and an embedded NATS JetStream store. Do not run it as a per-replica Kubernetes sidecar container. Three app replicas would give you three independent engines, three separate event streams, and three SQLite files on ephemeral storage — silent data divergence, not horizontal scaling.
| You want | Use instead |
|---|---|
| Multiple app replicas sharing one engine | Helm chart or Docker Compose with PostgreSQL + external NATS |
| Ironflow as its own deployable service | Self-hosting |
| Ironflow embedded in your binary | Not supported. Use this page. |
Single-node SQLite is the only mode this page covers. Multi-node clustering requires PostgreSQL and an external NATS server.
1. Start the engine
ironflow serve --host 127.0.0.1 --port 9123--host 127.0.0.1 binds loopback only. The default (empty) binds every interface,
which exposes the engine to your LAN — pass the flag explicitly when the engine is
meant for one local consumer.
Do not pass --host 127.0.0.1 under Docker Compose. Containers reach each other
across the project’s bridge network, not over loopback, so binding to 127.0.0.1
inside the container makes the engine unreachable from your app.
State lands in .ironflow/ relative to the working directory:
.ironflow/├── ironflow.db SQLite database├── ironflow-nats/ JetStream store├── blobs/ blob overflow storage├── .ironflow_jwt_secret (mode 0600)└── .ironflow_bootstrap_key.json first-boot admin key (mode 0400)Override the location with --db /var/lib/myapp/ironflow.db; the NATS store and
bootstrap key follow the database directory.
2. Read the bootstrap API key
On first boot Ironflow writes an admin API key to a file — never to stdout, so it does not leak into your logs:
cat .ironflow/.ironflow_bootstrap_key.json | jq -r .key# ifkey_...Set the file path explicitly when the working directory is not stable:
ironflow serve --host 127.0.0.1 --bootstrap-key-file /run/myapp/ironflow-key.jsonRead the key once, hand it to your app, then delete the file and rotate with
ironflow apikey rotate ak_admin_bootstrap. See
API keys for the full rotation story.
For local development you can skip auth entirely with ironflow serve --dev, which
bypasses authentication. Never use --dev in production.
3. Point your app at it
Both worker SDKs read the same two environment variables:
| Variable | Purpose | Default |
|---|---|---|
IRONFLOW_SERVER_URL | Engine base URL | http://localhost:9123 |
IRONFLOW_API_KEY | API key for authentication | none |
Your application is an ordinary SDK worker — it does not know or care that the engine happens to be on the same machine. Pick your language:
| Language | What it can do here |
|---|---|
| Go | Full worker — durable steps, projections |
| TypeScript | Full worker — durable steps, projections |
| Python | Client only — emit events, query runs. No worker runtime |
| Anything else | REST API — client calls are easy, workers are a protocol you implement yourself |
package main
import ( "context" "log" "os" "os/signal" "syscall"
"github.com/sahina/ironflow-go/ironflow")
var Hello = ironflow.CreateFunction( ironflow.FunctionConfig{ ID: "sidecar-hello", Name: "Sidecar Hello", Mode: ironflow.PullMode, Triggers: []ironflow.Trigger{{Event: "sidecar.ping"}}, }, func(ctx ironflow.Context) (any, error) { return ironflow.Run(ctx, "say-hello", func() (map[string]any, error) { return map[string]any{"ok": true}, nil }) },)
func main() { // ServerURL and APIKey fall back to IRONFLOW_SERVER_URL / IRONFLOW_API_KEY. w := ironflow.NewWorker(ironflow.WorkerConfig{ Functions: []ironflow.Function{Hello}, })
ctx, cancel := context.WithCancel(context.Background()) defer cancel()
sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) go func() { <-sig cancel() w.Drain() }()
if err := w.Run(ctx); err != nil { log.Fatalf("worker error: %v", err) }}Run it:
IRONFLOW_SERVER_URL=http://127.0.0.1:9123 \IRONFLOW_API_KEY=$(jq -r .key .ironflow/.ironflow_bootstrap_key.json) \ ./your-appExpected output:
[ironflow-worker] [INFO] Starting worker workerId=worker-... functions=1[ironflow-worker] [INFO] Registered function functionId=sidecar-hello[ironflow-worker] [INFO] Connected to serverimport { ironflow, createWorker } from "@ironflow/node";
const hello = ironflow.createFunction( { id: "sidecar-hello", mode: "pull", triggers: [{ event: "sidecar.ping" }], }, async ({ step }) => { return await step.run("say-hello", async () => ({ ok: true })); },);
// serverUrl and apiKey fall back to IRONFLOW_SERVER_URL / IRONFLOW_API_KEY.const worker = createWorker({ functions: [hello] });
process.on("SIGINT", () => worker.stop());process.on("SIGTERM", () => worker.stop());
await worker.start();Run it:
IRONFLOW_SERVER_URL=http://127.0.0.1:9123 \IRONFLOW_API_KEY=$(jq -r .key .ironflow/.ironflow_bootstrap_key.json) \ node ./dist/index.jsThe Python SDK is client-only — it can emit events and query runs, projections, KV,
and config, but it ships no worker runtime. There is no step.run, no push mode, and no
pull mode, so a Python process cannot be the worker in this topology.
Install it as ironflow-py (pip install ironflow-py, available from v0.33.0), not
ironflow — that bare name on PyPI belongs to an unrelated project. The import name
stays ironflow.
import osfrom protobuf.wkt import Structfrom ironflow import IronflowClient, IronflowRPCfrom ironflow.rpc import v1
client = IronflowClient( server_url=os.environ.get("IRONFLOW_SERVER_URL", "http://127.0.0.1:9123"), api_key=os.environ["IRONFLOW_API_KEY"],)
with IronflowRPC(server_url=client.server_url, api_key=client.api_key) as rpc: # Emit an event — a Go or TypeScript worker picks it up. rpc.events.emit(v1.TriggerRequest(event='sidecar.ping', data=Struct.from_python({})))
# Read back what happened. runs = rpc.runs.list(v1.ListRunsRequest())The workable pattern: your Python app is the producer, and a small Go or TypeScript worker (also alongside, a third process) runs the durable functions. The REST client and the generated Connect client cover the producer and reader calls.
No SDK for your language? Everything Ironflow does is reachable over HTTP, so a Rust, Ruby, Java, or C# app talks to it directly. Authenticate with a bearer token:
# Emit an eventcurl -X POST http://127.0.0.1:9123/ironflow.v1.IronflowService/Emit \ -H "Authorization: Bearer $IRONFLOW_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event": "sidecar.ping", "data": {}}'
# Query runscurl -s -H "Content-Type: application/json" -d '{}' http://127.0.0.1:9123/ironflow.v1.IronflowService/ListRuns \ -H "Authorization: Bearer $IRONFLOW_API_KEY"That is the easy half, and for most apps it is enough — emit events, read runs and projection state, let a Go or TypeScript worker do the durable execution.
Being a worker over raw HTTP is also supported but is real work. The pull protocol is
five endpoints — POST /workers/{id}/register, POST /workers/{id}/heartbeat,
GET /workers/{id}/jobs, PUT /workers/{id}/jobs/{jobId}/ack, and
PUT /workers/{id}/jobs/{jobId} — and on top of those you implement the heartbeat loop
and step memoization that the SDKs give you for free. See
Workers (Pull Mode) for the request and
response shapes.
Use pull mode for this topology whichever SDK you pick. Push mode would require the engine to reach an HTTP endpoint your app exposes; pull mode has the worker poll the engine, so only one process needs to be reachable.
systemd
Two units, ordered so the engine comes up first. The engine unit is unremarkable:
[Unit]Description=Ironflow engineAfter=network.target
[Service]Type=simpleUser=myappWorkingDirectory=/var/lib/myappExecStart=/usr/local/bin/ironflow serve --host 127.0.0.1 --port 9123Restart=alwaysRestartSec=5
[Install]WantedBy=multi-user.targetYour app declares the dependency:
[Unit]Description=My applicationAfter=ironflow.serviceRequires=ironflow.service
[Service]Type=simpleUser=myappEnvironment=IRONFLOW_SERVER_URL=http://127.0.0.1:9123EnvironmentFile=/etc/myapp/ironflow.envExecStart=/usr/local/bin/my-appRestart=alwaysRestartSec=5
[Install]WantedBy=multi-user.targetRequires= plus After= means systemd starts the engine first, and stops your app if
the engine unit is explicitly stopped. It does not wait for the engine to be
ready — only for its process to exist — and with Restart=always on the engine, an
engine crash does not take your app down. The SDK worker retries on connection failure
(ReconnectDelay, default 5s), so a crash-restart cycle heals itself.
If you want a hard readiness gate at startup, add a bounded wait to the engine unit:
ExecStartPost=/bin/sh -c 'for i in $(seq 1 60); do curl -sf http://127.0.0.1:9123/ready && exit 0; sleep 1; done; exit 1'The loop is bounded on purpose — an unbounded until loop hangs unit startup forever
if the engine never becomes ready.
Put IRONFLOW_API_KEY=ifkey_... in /etc/myapp/ironflow.env with mode 0600, owned by
the service user.
Docker Compose
Two services on the same Compose network. The engine keeps a named volume for its state:
services: ironflow: image: ghcr.io/sahina/ironflow-releases:${VERSION:-latest} # The image's WORKDIR is /app, but only /data is a volume — point the # database at /data explicitly or your state dies with the container. command: ["serve", "--db", "/data/ironflow.db"] environment: - NATS_STORE_DIR=/data/nats volumes: - ironflow-data:/data healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:9123/ready"] interval: 5s timeout: 3s retries: 20
myapp: build: . depends_on: ironflow: condition: service_healthy environment: IRONFLOW_SERVER_URL: http://ironflow:9123 IRONFLOW_API_KEY: ${IRONFLOW_API_KEY}
volumes: ironflow-data:condition: service_healthy gives you the ordering guarantee systemd does not. Note the
engine is not published to the host here — only myapp can reach it, over the
Compose network.
First boot is a two-step, because IRONFLOW_API_KEY does not exist until the engine has
started once:
# 1. Engine only — this creates the bootstrap keydocker compose up -d ironflow
# 2. Read the key into .envecho "IRONFLOW_API_KEY=$(docker compose exec -T ironflow \ cat /data/.ironflow_bootstrap_key.json | jq -r .key)" >> .env
# 3. Now bring up your appdocker compose up -dRotate the key afterwards with ironflow apikey rotate ak_admin_bootstrap. On every
subsequent docker compose up both services start together — the key is already in
.env.
Do not scale this with docker compose up --scale myapp=3 unless every replica points
at the same single ironflow service. Scaling ironflow itself is the failure mode
described in When not to use this.
Health checks
| Endpoint | Checks | Use for |
|---|---|---|
/health | Database reachable | Liveness — restart the engine if this fails |
/ready | Database and NATS reachable | Readiness — gate your app’s startup on this |
Both return 200 when healthy:
curl -sf http://127.0.0.1:9123/ready && echo upVerify the whole thing
# 1. Engine is upcurl -sf http://127.0.0.1:9123/ready
# 2. Your worker registered its functionsironflow function list
# 3. Fire a test event and watch it runironflow emit sidecar.ping --waitironflow run list --limit 5Expected output:
ID TRIGGERS MODEsidecar-hello sidecar.ping pull
✓ sidecar-hello RUN_STATUS_COMPLETED 8ms {"ok":true}
ID FUNCTION STATUS STARTED9034b744-39a8-... sidecar-hello completed 2026-08-11 02:26:52All three client commands read the same IRONFLOW_SERVER_URL and IRONFLOW_API_KEY
your app does, so export them once in your shell. --wait blocks for up to 30 seconds
and needs your worker already running — without it the event is queued and the command
times out.
Steps 2 and 3 only apply if your app runs a worker (Go or TypeScript). A client-only
app — Python, or anything on the raw REST API — registers no functions, so function list is legitimately empty and emit --wait has nothing to wait for. For those, step 1
plus a successful POST /ironflow.v1.IronflowService/Emit is the whole check.
If step 2 shows nothing on a worker app, it connected but registered no functions —
check the worker’s functions list is populated. If your app logs a connection error,
check IRONFLOW_SERVER_URL (the worker SDKs read IRONFLOW_SERVER_URL, not
IRONFLOW_URL).
Related
- Self-hosting — Ironflow as its own service
- Docker Compose Deployment — multi-node with PostgreSQL and external NATS
- Execution modes — push vs pull, in both worker SDKs
- SDK comparison — what each SDK actually supports
- REST API — every endpoint, including the worker pull protocol
- API keys — bootstrap key handling and rotation
- Configuration reference — every flag and environment variable