Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Deployment Overview

Ironflow ships as one engine, published two ways: the container image ghcr.io/sahina/ironflow-releases:<version> and the ironflow binary. The image runs the same binary, which embeds NATS JetStream and uses SQLite or PostgreSQL. To self-host Ironflow, run the engine container with the Docker Compose files from the release repository.

OptionRuns onSizesGuide
LocalYour machineOne processGetting Started
Docker ComposeOne machine with DockerSingle node, Starter, Cluster, HADocker Compose
Next to your appThe same host or Compose project as your serviceSingle nodeRun Ironflow alongside your app
Several hostsYour own hosts, with managed PostgreSQL and a NATS clusterAny number of nodesProduction on your own infrastructure

Every Compose size runs on one machine. Starter, Cluster and HA run Ironflow in cluster mode with PostgreSQL, NATS and an S3 store, so you can move up a size without changing how your app connects.

For local development, Ironflow needs no infrastructure:

Terminal window
ironflow serve --dev

This starts Ironflow at http://localhost:9123 with an embedded NATS JetStream broker and SQLite, in one process. See the Getting Started tutorial.

Ironflow has no library or in-process mode. To run it next to your own service, run the engine as a companion process on the same host, and talk to it from Go, TypeScript, Python, or raw HTTP. See Run Ironflow Alongside Your App.

To ship your app and the engine as one container, ask your AI agent, for example /ironflow-ops package my app. To deploy that image to Fly or Railway, ask /ironflow-ops deploy my app to fly. The ironflow-ops skill plans the deploy once, then stops for your yes before each step that costs money or is public. On a server of your own, run the image with Docker Compose. See AI Skills.

If /ready answers 503 or the JetStreamStreamFault alert fires while the API still serves, a JetStream stream has a fault. A host reset can cause this on Compose HA. See Recover a stuck JetStream stream.

Each Ironflow deployment supports the full resource hierarchy:

Organization → Project → Environment → Resources
  • Organization is the top-level tenant.
  • Projects group related environments, such as a microservice or a team boundary.
  • Environments within a project, such as dev, staging and production, isolate data.

API keys are scoped to one environment. NATS subjects for events include the project name and the environment ID. See Platform Architecture.

These environment variables apply to the flags-only path: ironflow serve without -f, which is how the Compose files start the engine. With -f file.yaml, the file is the source of truth: set the matching field, or reference the variable as ${VAR}.

SettingEnv VarPurpose
Database URLIRONFLOW_DATABASE_URLPostgreSQL connection string (omit for embedded SQLite)
Master keyIRONFLOW_MASTER_KEYAES-256 encryption key for secrets at rest
NATS URLNATS_URLExternal NATS connection (omit for embedded)
Log levelLOG_LEVELdebug, info, warn, error
MetricsIRONFLOW_METRICS_ENABLEDEnable Prometheus /metrics endpoint
TracingIRONFLOW_OTEL_ENDPOINTOpenTelemetry collector URL

See the Configuration Reference for the full list.

When Ironflow nodes and PostgreSQL run on more than one host, set the server-side TCP keepalives on PostgreSQL:

SettingRecommendedLinux default
tcp_keepalives_idle607200 seconds
tcp_keepalives_interval1075 seconds
tcp_keepalives_count69 probes

An Ironflow node holds a PostgreSQL advisory lock while it runs migrations at boot (an open transaction owns it). PostgreSQL releases the lock only when it sees the session end. A crashed process closes its socket, so its lock goes at once. A host that loses power or network does not. With the Linux defaults, PostgreSQL keeps that session and its lock for about 2 hours. Meanwhile a booting node waits for the migration lock, logs the holder every 30 seconds, and exits after 5 minutes with timed out waiting for the schema migration advisory lock. The error names the holder’s pid, client address, transaction start (xact_start) and state.

A SQL projection rebuild uses a lease on the projection’s registry row instead of a session advisory lock. Without a blocking transaction, lease expiry plus the next reconcile makes a lost node’s drain eligible for takeover in about 90 seconds, plus any rebuild batch pause. An unfinished batch transaction still holds a row lock, and lease expiry cannot release it. Actual recovery waits for PostgreSQL or the pooler to detect the lost client and end that transaction, a configured timeout, or operator termination. A claim skips a locked row so other projections can still drain; keep the server-side keepalives recommended here.

With the recommended values, PostgreSQL ends the dead session after about 2 minutes (60 + 6 × 10 seconds). Set them in postgresql.conf, with ALTER SYSTEM SET tcp_keepalives_idle = 60; and SELECT pg_reload_conf();, or in the parameter group of a managed database. You can also put them on the connection URL: options=-c%20tcp_keepalives_idle%3D60%20-c%20tcp_keepalives_interval%3D10%20-c%20tcp_keepalives_count%3D6. Client-side keepalives do not help here: PostgreSQL must detect the lost client.

To free the lock before keepalive ends the session, find the holder and end its session. state = idle in transaction is normal for the holder: the connection that holds the lock waits while the node migrates on other connections. xact_start is when the holder transaction began, which is when that node took the lock. Behind a pooler, client_addr is the pooler, not the node. The holder’s application_name names its node: ironflow-migrate <node> for the migration, ironflow-migrate-step <node> for a Go data step. <node> is the node ID from --node-id or YAML cluster.nodeId, or from IRONFLOW_NODE_ID on the flags-only path. Without a configured ID, the node generates a UUID and logs it as Node ID assigned. A process with no node ID uses hostname and pid. End the session only if that node is no longer running. xact_start shows how long it has held the lock.

PostgreSQL limits application_name to 63 bytes. Ironflow first escapes the node ID to printable ASCII, so PostgreSQL does not expand it again. Non-ASCII characters, controls and malformed bytes use Go-style escapes; quotes and backslashes are escaped too. For a long escaped ID, Ironflow keeps the full phase prefix and shows ... plus the tail of the escaped text. The tail can start inside an escape sequence. For example, ironflow-migrate-step ...<pod-name-tail> retains the pod suffix. Match that tail to the node or pod before ending its session; if it is ambiguous, identify the node first. With -f file.yaml, YAML is the source of truth and IRONFLOW_NODE_ID is ignored; set cluster.nodeId or reference the environment variable in that field.

SELECT a.pid, a.application_name, a.client_addr, a.xact_start, a.state
FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory' AND l.granted;
SELECT pg_terminate_backend(<pid>);

A migration that takes longer than 5 minutes on a large database, such as a data backfill or a new index on events, makes the other booting nodes exit and restart until it ends. Set IRONFLOW_MIGRATION_LOCK_WAIT (a Go duration, such as 30m) to make them wait longer. Ironflow reads it with and without -f file.yaml.

The schema migration lock is a transaction lock, so it holds behind PgBouncer in transaction mode. The pooler pool needs at least 2 server connections: the migration holds one for its run and applies the files on another. The SQL projection rebuild drain holds a lease row, not a lock, so it also works behind a transaction-mode pooler. Stop every old node before you start this version: the old session lock and the new drain lease do not exclude each other.