Skip to content

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 wantUse instead
Multiple app replicas sharing one engineHelm chart or Docker Compose with PostgreSQL + external NATS
Ironflow as its own deployable serviceSelf-hosting
Ironflow embedded in your binaryNot 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

Terminal window
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:

Terminal window
cat .ironflow/.ironflow_bootstrap_key.json | jq -r .key
# ifkey_...

Set the file path explicitly when the working directory is not stable:

Terminal window
ironflow serve --host 127.0.0.1 --bootstrap-key-file /run/myapp/ironflow-key.json

Read 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:

VariablePurposeDefault
IRONFLOW_SERVER_URLEngine base URLhttp://localhost:9123
IRONFLOW_API_KEYAPI key for authenticationnone

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:

LanguageWhat it can do here
GoFull worker — durable steps, projections
TypeScriptFull worker — durable steps, projections
PythonClient only — emit events, query runs. No worker runtime
Anything elseREST 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:

Terminal window
IRONFLOW_SERVER_URL=http://127.0.0.1:9123 \
IRONFLOW_API_KEY=$(jq -r .key .ironflow/.ironflow_bootstrap_key.json) \
./your-app

Expected output:

[ironflow-worker] [INFO] Starting worker workerId=worker-... functions=1
[ironflow-worker] [INFO] Registered function functionId=sidecar-hello
[ironflow-worker] [INFO] Connected to server

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:

/etc/systemd/system/ironflow.service
[Unit]
Description=Ironflow engine
After=network.target
[Service]
Type=simple
User=myapp
WorkingDirectory=/var/lib/myapp
ExecStart=/usr/local/bin/ironflow serve --host 127.0.0.1 --port 9123
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

Your app declares the dependency:

/etc/systemd/system/myapp.service
[Unit]
Description=My application
After=ironflow.service
Requires=ironflow.service
[Service]
Type=simple
User=myapp
Environment=IRONFLOW_SERVER_URL=http://127.0.0.1:9123
EnvironmentFile=/etc/myapp/ironflow.env
ExecStart=/usr/local/bin/my-app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

Requires= 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:

Terminal window
# 1. Engine only — this creates the bootstrap key
docker compose up -d ironflow
# 2. Read the key into .env
echo "IRONFLOW_API_KEY=$(docker compose exec -T ironflow \
cat /data/.ironflow_bootstrap_key.json | jq -r .key)" >> .env
# 3. Now bring up your app
docker compose up -d

Rotate 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

EndpointChecksUse for
/healthDatabase reachableLiveness — restart the engine if this fails
/readyDatabase and NATS reachableReadiness — gate your app’s startup on this

Both return 200 when healthy:

Terminal window
curl -sf http://127.0.0.1:9123/ready && echo up

Verify the whole thing

Terminal window
# 1. Engine is up
curl -sf http://127.0.0.1:9123/ready
# 2. Your worker registered its functions
ironflow function list
# 3. Fire a test event and watch it run
ironflow emit sidecar.ping --wait
ironflow run list --limit 5

Expected output:

ID TRIGGERS MODE
sidecar-hello sidecar.ping pull
✓ sidecar-hello RUN_STATUS_COMPLETED 8ms {"ok":true}
ID FUNCTION STATUS STARTED
9034b744-39a8-... sidecar-hello completed 2026-08-11 02:26:52

All 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).