- Deploy & Operate
- 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, TypeScript and Python have full worker SDKs; 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
Section titled “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 one copy per app replica, such as a sidecar container in each pod. 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 | Docker Compose Starter, Cluster or HA, which run PostgreSQL and NATS |
| Ironflow as its own deployable service | Docker Compose |
| 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
Section titled “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)└── engine.json discovery file, may hold the admin key (mode 0600)Override the location with --db /var/lib/myapp/ironflow.db; the NATS store, bootstrap
key, and discovery file follow the database directory. engine.json is written under SQLite
when --host is empty (the default), localhost or an IPv4 loopback address, and not for any other
--host. The engine removes it on graceful shutdown.
2. Read the bootstrap API key
Section titled “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
Section titled “3. Point your app at it”All three 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 | Full worker — durable steps, projections |
| 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 environment=[ironflow-worker] [INFO] Registered function functionId=sidecar-hello[ironflow-worker] [INFO] Connected to serverimport { createFunction, createWorker } from "@ironflow/node";
const hello = 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.jsInstall it as ironflow-py (pip install ironflow-py), not ironflow — that bare
name on PyPI belongs to an unrelated project. The import name stays ironflow.
from ironflow.worker import Worker, function
@function(id="sidecar-hello", name="Sidecar Hello", triggers=[{"event": "sidecar.ping"}])async def hello(ctx): return await ctx.step.run("say-hello", lambda: {"ok": True})
# server_url and api_key fall back to IRONFLOW_SERVER_URL / IRONFLOW_API_KEY.# run() drains on the first SIGINT or SIGTERM and stops on the second.Worker(functions=[hello]).run()Run it:
IRONFLOW_SERVER_URL=http://127.0.0.1:9123 \IRONFLOW_API_KEY=$(jq -r .key .ironflow/.ironflow_bootstrap_key.json) \ python app.pyNo 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, TypeScript or Python 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 /api/v1/workers/{id}/register, POST /api/v1/workers/{id}/heartbeat,
GET /api/v1/workers/{id}/jobs, PUT /api/v1/workers/{id}/jobs/{jobId}/ack, and
PUT /api/v1/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
Section titled “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
Section titled “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 /data, which is the volume. --db pins the database path # there explicitly, so the bootstrap key path below does not depend on the WORKDIR. 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
Section titled “Health checks”| Endpoint | Checks | Use for |
|---|---|---|
/health | Database reachable | Liveness: restart the engine if this fails. Route traffic on it |
/ready | Database and NATS reachable, and every engine stream serviceable | Readiness: gate your app’s startup on this. Do not route traffic on it |
Both return 200 when healthy:
curl -sf http://127.0.0.1:9123/ready && echo upVerify the whole thing
Section titled “Verify 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, TypeScript or Python). A
client-only app — 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 it).
Related
Section titled “Related”- Docker Compose — Ironflow as its own service, single node or cluster
- Docker Compose Deployment — multi-node with PostgreSQL and external NATS
- Execution modes — push vs pull, in the 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