- CLI Reference
- Configuration
Configuration
This page documents all environment variables and default settings for the Ironflow server and its SDKs.
Environment Variables
Section titled “Environment Variables”| Variable | Description | Default |
|---|---|---|
IRONFLOW_SERVER_URL | The URL SDKs and CLI use to connect. An explicit argument or flag comes first, then IRONFLOW_SERVER_URL, then the default. Empty values count as unset. Agent memory, exposeMcp, and serve (Node and Python) push publishing have no default: they need IRONFLOW_SERVER_URL or an explicit value. Node serve() skips webhook emit when no URL is set. | http://localhost:9123 |
IRONFLOW_API_KEY | The key used for authentication. | (None) |
IRONFLOW_DATABASE_URL | PostgreSQL connection string, which selects the PostgreSQL backend. | (None, uses SQLite) |
IRONFLOW_BLOB_URL | Blob overflow backend for large run/step outputs. Empty uses local filesystem next to the SQLite DB; s3://bucket?endpoint=...®ion=... selects an S3-compatible backend. PostgreSQL deployments require an s3:// value, even with offload disabled. See Artifact overflow to blob storage. | (None, local filesystem) |
IRONFLOW_ARTIFACT_THRESHOLD | Size above which run/step outputs offload to the blob backend (1MB, 512KiB, etc.). Empty or 0 disables new offloads; reads still inflate existing blobs. | (None, off) |
IRONFLOW_FILES_MAX_OBJECT_BYTES | Server-wide size cap for one stored file (100MiB, 104857600, etc.). A bucket cap can be lower, never higher. Overrides spec.files.maxObjectBytes. File bytes use the IRONFLOW_BLOB_URL backend. See Files. | 104857600 (100 MiB) |
IRONFLOW_FILES_RECONCILE | Background file cleanup mode: on retries recorded retirements, dry-run reports queue and prefix state without mutations, off starts no worker. Explicit API cleanup runs in every mode. Overrides spec.files.reconcile. See Files. | on |
IRONFLOW_FILES_PUBLIC_BASE_URL | External absolute HTTP(S) base URL for signed file URLs, including an optional proxy path prefix. Overrides spec.files.publicBaseURL on both serve config paths. Credentials, query strings and fragments are rejected. See Files. | Request origin |
AWS_ACCESS_KEY_ID | Access key used by the S3-compatible blob backend when IRONFLOW_BLOB_URL is set. | (None) |
AWS_SECRET_ACCESS_KEY | Secret key used by the S3-compatible blob backend when IRONFLOW_BLOB_URL is set. | (None) |
AWS_SESSION_TOKEN | Optional session token for temporary S3 credentials. | (None) |
IRONFLOW_MASTER_KEY | 32-byte hex key for secrets encryption. | (None, Dev Mode) |
IRONFLOW_JWT_SECRET | 32-byte hex key for dashboard JWT signing. Required under PostgreSQL unless jwtSecretFile is configured. Under SQLite, auto-generated and persisted to a private regular .ironflow_jwt_secret if not set; unsafe files or persistence failures prevent startup. | (Auto-generated) |
IRONFLOW_JWT_SECRET_FILE | Existing private raw hex JWT key file owned by the engine user. Mutually exclusive with IRONFLOW_JWT_SECRET; supports persistent API rotation. Flags-only. | (None) |
IRONFLOW_POLICY_ENFORCEMENT | Set to true or 1 to enforce stored Rego policy sets on top of RBAC. An evaluation error denies the request in enforce mode. Flags-only path; with -f use spec.auth.policyEnforcement. See Authorization policy sets. | false |
IRONFLOW_MCP_REMOTE | true serves MCP at /api/v1/mcp so hosted agents can use their own API key. Every tool call runs with that key’s permissions. Parsed as a Go boolean. Not read under -f; set spec.mcp.remote instead. See Remote MCP. | false |
IRONFLOW_SIGNING_KEY | HMAC-SHA256 key the server signs push-mode requests with, in the X-Ironflow-Signature header. Give the same value to the function side so it can verify the header. With no key set, the server sends no signature. | (None) |
IRONFLOW_WEBHOOK_SECRET_GRACE_HOURS_DEFAULT | Default grace window (integer hours) for webhook secret rotation. Values above the 7-day cap or unparseable values are fatal at boot. | 24 |
IRONFLOW_ENV | Default environment ID for the CLI, and for SDK workers, push serve handlers and agent MCP registration (for example env_default). An explicit flag or option comes first. SDK clients do not read it: pass environment to the client. When unset, most CLI commands use the API key’s own environment; the ironflow outbox dlq commands require --env or this variable. | (None) |
IRONFLOW_OUTPUT | Set to json to make any CLI command print an error as one JSON object on stdout. Same as --json for errors. See CLI reference. | (None, text) |
IRONFLOW_CONFIG_DIR | Directory for storing platform credentials (credentials.json). Used by ironflow platform login. | ~/.config/ironflow |
IRONFLOW_MCP_BEARER_TOKEN | Bearer token required by ironflow mcp --transport=streamable-http. Required for that transport; the server refuses to start without it and never accepts a non-loopback host. Unused by the default stdio transport. | (None) |
IRONFLOW_MCP_EXIT_ON_STDIN_CLOSE | Set to 1 to make ironflow mcp --transport=streamable-http exit when its stdin closes. A supervisor that spawns it with a stdin pipe then takes it down even when the supervisor is killed. @ironflow/agent sets it. | (None) |
IRONFLOW_A2A_BEARER_TOKEN | Bearer token that remote callers present to ironflow a2a. See ironflow a2a --help. | (None) |
IRONFLOW_TERMINAL_BRIDGE_TOKEN | Token for the loopback terminal bridge that Ironflow Desktop hands to ironflow mcp --terminal-bridge-url. Required with that flag. | (None) |
NATS_STORE_DIR | Disk path for embedded NATS JetStream storage. | (Auto-derived from --db path: {db_dir}/ironflow-nats. See Embedded NATS storage below.) |
NATS_SYNC_INTERVAL | Embedded JetStream fsync policy: always, or a Go duration such as 1s. Flags-only path; with -f use spec.nats.syncInterval. An explicit --nats-sync-interval wins. See Write durability below. | always |
LOG_LEVEL | trace, debug, info, warn, error. Overrides per-command defaults. | warn (CLI), info (serve) |
LOG_FORMAT | text for human-readable output. Only affects serve command (JSON by default). CLI commands always use text. | text (CLI), JSON (serve) |
PostgreSQL connection strings
Section titled “PostgreSQL connection strings”IRONFLOW_DATABASE_URL and YAML spec.storage.url accept pgx connection strings,
either postgres:// / postgresql:// URIs or keyword/value strings. Ironflow
passes the connection string to pgx unchanged. Positive spec.storage.pool
settings override the corresponding pool options in the DSN. For example, set
maxConns: 0 to let pool_max_conns take effect instead of the default 25.
The pgx 5.11 upgrade changes how some existing strings are interpreted. Check connection strings assembled by templates or generic URL encoders before upgrading:
| Input | Current meaning and upgrade guidance |
|---|---|
Query application_name=ironflow+migrate | + is a literal plus, previously a space. Use %20 for a space and %2B for a literal plus. The same rule applies to query passwords. Userinfo already treated + literally. |
Query sslmode=require&sslmode=disable | The last occurrence wins, previously the first. This also applies to passwords, timeouts and pool settings. Use each query key once to avoid accidentally changing authentication, TLS or pool behavior. |
Userinfo user:p@ss@host | The first @ ends userinfo. Percent-escape reserved characters in usernames and passwords, for example p%40ss for p@ss. |
Hosts host1,host2:5433 | Ports align with hosts: host1:5432, host2:5433. Previously both used 5433. Give every host its port explicitly, for example host1:5432,host2:5433. |
Keyword password='a\qb' | A backslash escapes any following character, yielding aqb. Use password='a\\qb' for a literal backslash. |
Use percent-encoding for URI spaces; raw interior spaces are rejected. Malformed
percent escapes and NUL bytes are rejected too. A raw # is data, rather than a
URL fragment delimiter. Unix sockets remain supported, for example
postgres://ironflow@/ironflow?host=/var/run/postgresql&sslmode=disable.
Keyword strings use whitespace between assignments. Single-quote values with
spaces, and backslash-escape literal quotes and backslashes within them. For
example, the DSN below sets the password to p\q'r and the application name to
ironflow migrate:
host=localhost port=5432 user=ironflow password='p\\q\'r' dbname=ironflow application_name='ironflow migrate' sslmode=requireKeep DSNs secret. ironflow validate prints
Storage: postgres (connection string redacted) without echoing any part of the
connection string. Validation checks the configuration file; it does not connect
to PostgreSQL or verify the DSN credentials.
Credential and certificate files
Section titled “Credential and certificate files”pgx 5.11 changes implicit file discovery on Linux and macOS. Defaults now use
$HOME: .pgpass, .pg_service.conf, and
.postgresql/{root.crt,postgresql.crt,postgresql.key}. Previously pgx used the home
returned by the OS account lookup. If $HOME differs from that location or is
unset, files previously discovered may no longer be found. For a TCP connection
using sslmode=require without an explicit CA override, losing the default
root.crt removes certificate-chain verification.
Pin required files with explicit absolute DSN paths: passfile, servicefile,
sslrootcert, sslcert, and sslkey. Their environment equivalents are
PGPASSFILE, PGSERVICEFILE, PGSSLROOTCERT, PGSSLCERT, and PGSSLKEY.
DSN settings take precedence over selected service settings, then environment
settings, then defaults. The service file to load is selected from the DSN,
environment or default before its settings are read. A service file is read only
when a service is selected; a password file is read only when no password has
resolved. Use
sslmode=verify-full with a trusted sslrootcert when server identity
verification is required.
On Windows, the default service file now uses
%USERPROFILE%\.pg_service.conf. Password and TLS defaults use
%APPDATA%\postgresql only when APPDATA is set.
Timestamp results
Section titled “Timestamp results”pgx 5.11 decodes text-format timestamptz results in the client’s local timezone,
matching its binary-format behavior. Previously text results kept the server’s
numeric offset. This can change displayed offsets when using
default_query_exec_mode=simple_protocol. The represented instants and normal
PostgreSQL microsecond storage precision are unchanged. Ironflow’s SQL query
rendering still formats time.Time cells in UTC; other typed results can retain
the client’s offset.
S3 Usage at a Glance
Section titled “S3 Usage at a Glance”Ironflow uses S3-compatible storage in a few different places. These settings are intentionally separate because they protect different data.
| Surface | What goes to S3 | Dev / single-node default | Production setting | Configure with |
|---|---|---|---|---|
| Runtime blob overflow | Large run and step outputs after they exceed the configured threshold. Reads inflate the payload transparently. | Local filesystem next to the SQLite DB; offload is disabled until a threshold is set. | Multi-node deployments require a shared s3:// backend, even when new offloads are disabled. | IRONFLOW_ARTIFACT_THRESHOLD, IRONFLOW_BLOB_URL, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, optional AWS_SESSION_TOKEN; YAML: spec.blobs.artifactThreshold, spec.blobs.url. See Artifact overflow to blob storage. |
| File storage | User files from the file storage API, under the key prefix files/. | Local filesystem next to the SQLite DB. File bytes are not counted by the database size limit, and there is no quota: monitor the volume. | Multi-node deployments require a shared s3:// backend and one auth.jwtSecret on every node (for signed URLs). | Same IRONFLOW_BLOB_URL and AWS_* variables as blob overflow; size cap IRONFLOW_FILES_MAX_OBJECT_BYTES or YAML spec.files.maxObjectBytes. |
| Cloud operator OpenTofu state and module inputs | Per-customer OpenTofu state (customers/<slug>/state) and the module inputs every customer cluster shares. | Not used by normal ironflow serve; required by the ironflow-cloud saga worker. | Set the state bucket, endpoint, region and credentials, the Hetzner SSH key ids (comma-separated) and the operator SSH allowlist CIDRs (comma-separated). | IRONFLOW_CLOUD_TFSTATE_BUCKET, IRONFLOW_CLOUD_TFSTATE_ENDPOINT, IRONFLOW_CLOUD_TFSTATE_REGION, IRONFLOW_CLOUD_TFSTATE_ACCESS_KEY, IRONFLOW_CLOUD_TFSTATE_SECRET_KEY, IRONFLOW_CLOUD_TOFU_SSH_KEY_IDS, IRONFLOW_CLOUD_TOFU_OPERATOR_CIDRS. |
| Cloud operator break-glass and snapshots | Encrypted break-glass payloads and deprovision snapshot exports for managed-cloud workflows. | Not used by normal ironflow serve; only used by ironflow-cloud workflows. | Set the cloud operator S3 endpoint, bucket, credentials, region, and TLS flag. | IRONFLOW_CLOUD_BREAK_GLASS_ENDPOINT, IRONFLOW_CLOUD_BREAK_GLASS_REGION, IRONFLOW_CLOUD_BREAK_GLASS_BUCKET, IRONFLOW_CLOUD_BREAK_GLASS_ACCESS_KEY, IRONFLOW_CLOUD_BREAK_GLASS_SECRET_KEY, IRONFLOW_CLOUD_BREAK_GLASS_SECURE. |
The broader object/file storage API described in ADR-0035 is separate design
work and is not configured by IRONFLOW_STORAGE_* variables in the shipped
server path.
See NATS trigger acceptance and retention for EVENTS capacity errors, positive PubAck requirements, and stopped-writer upgrades.
Embedded NATS storage
Section titled “Embedded NATS storage”ironflow serve runs an embedded NATS JetStream server when NATS_URL is not
set. JetStream needs a storage directory for streams and consumer state.
Default behavior (since #614): the store directory is auto-derived from the
SQLite database path. For the default .ironflow/ironflow.db it becomes .ironflow/ironflow-nats/;
for /data/ironflow.db it becomes /data/ironflow-nats/. The directory is
created at startup with 0700 permissions (owner read/write/exec only) because
JetStream stream files contain workflow event payloads that may include PII or
secrets. Existing directories are left as-is so operators can pre-create with
their preferred ownership and permissions.
Memory-mode opt-out: pass an explicit empty value for testing, demos, or ephemeral CI:
ironflow serve --nats-store-dir=""The explicit empty CLI flag wins over a NATS_STORE_DIR environment variable
inherited from the shell or systemd unit.
YAML-only opt-out is not currently supported. YAML round-trip cannot
distinguish nats.storeDir: "" from an omitted field, so a YAML config alone
cannot request memory mode. YAML operators who want ephemeral embedded NATS
have two options today: (1) point at an external NATS server via nats.url,
or (2) override at startup with --nats-store-dir="" on the command line. A
discoverable :memory: sentinel for --nats-store-dir and NATS_STORE_DIR
is tracked in TODOS.md as a follow-up.
Postgres mode: when IRONFLOW_DATABASE_URL is set and NATS_URL is NOT set
(embedded NATS), ironflow serve and ironflow validate refuse to start
without an explicit --nats-store-dir or NATS_STORE_DIR. Auto-deriving from
the SQLite path is not safe — there is no SQLite path in Postgres mode, and
silently creating a directory in the current working directory is dangerous
under systemd units, container WorkingDirectory: /, and read-only cwd. Set
the directory explicitly when you want embedded NATS with Postgres, or use
--nats-url for an external NATS server.
:memory: SQLite: same fail-fast as Postgres mode. --db :memory: plus
embedded NATS without an explicit store dir is rejected at startup.
Security note — secrets KV plaintext on disk. The secrets KV bucket is
backed by JetStream file storage. When IRONFLOW_MASTER_KEY is unset, secret
values are stored without encryption (passthrough). Before #614 this was
masked by memory-mode embedded NATS; with persistent NATS as the default,
secrets without a master key persist plaintext to {store_dir}/secrets/....
The server emits a startup WARN when this configuration is detected and a
stronger WARN with the count of plaintext secrets when any exist. Set
IRONFLOW_MASTER_KEY to a 32-byte hex value (e.g. openssl rand -hex 32) for
AES-256-GCM encryption, then re-set existing secrets via ironflow secret set
so they get re-encrypted.
Storage profile. File-storage streams retain about 1.7 GB total
(internal/nats/streams.go), versus about 75 MB total in memory mode.
The per-stream limits are fixed in code. Operators on size-constrained disks can
roll back to memory mode with --nats-store-dir="".
Operator discipline. The embedded NATS server does not lock its store
directory, so two ironflow serve processes against the same DB directory
will silently corrupt JetStream state. Run a single process per directory.
Write durability
Section titled “Write durability”With a store directory, the embedded server fsyncs every JetStream write before
it acknowledges the write (sync_interval: always). The nats-server default is
different: it acknowledges at once and fsyncs every two minutes, so an OS crash
or power loss can roll back writes that were already acknowledged. Some state
lives only in JetStream: secrets and config KV, user KV, cron and debounce
state, and direct pub/sub publishes.
Fsync on every write costs throughput, and runs wait for it: each step and each run completion publishes to JetStream before the engine moves on. To trade durability for speed, set an interval:
ironflow serve --nats-sync-interval=1s # or NATS_SYNC_INTERVAL=1sWith -f, set spec.nats.syncInterval: 1s. A value that is not always or a
positive Go duration stops serve and fails ironflow validate.
External NATS (--nats-url): Ironflow cannot set or read this policy. Set
it on every NATS server, in the jetstream block of its config file:
jetstream { store_dir: /data sync_interval: always}nats-server has no command-line flag for it. If you start the server with -sd
(--store_dir), leave store_dir out of the block: nats-server refuses to
start with Duplicate 'store_dir' configuration when both are set. The Docker
Compose files pass -sd and a config with sync_interval: always and
limits { default_max_consumers: -1 }.
External NATS on 2.15 or later: nats-server 2.15 gives every stream a default
limit of 1000 consumers. Earlier versions have no default limit. The limit
covers the stream behind each KV bucket. Each KV watch and key listing holds one
consumer while it runs, so once 1000 are open at the same time on one bucket,
new ones fail. The limit counts consumers, not keys. It does not delete
consumers that exist. It blocks new ones.
Ironflow cannot set or read this option on a server it does not run. On each
2.15 server, set default_max_consumers: -1 in the limits block, next to
sync_interval:
jetstream { store_dir: /data sync_interval: always limits { default_max_consumers: -1 }}Add it only on a server that runs 2.15 or later. nats-server 2.14 and earlier do not know the field and fail to start with it. Before you move a server back to 2.14, remove the field.
Ironflow ships NATS 2.15.2 with this default disabled. It always sets explicit
MaxConsumers on its own streams: 1,000 on EVENTS, STEPS, RESULTS and CANCEL,
and 100,000 on PUBSUB. KV watches and listings remain unlimited on shipped
servers. External servers must run 2.14.7 or later; serve warns on 2.15 or
later because it cannot verify the server setting. A consumer-limit failure
names the stream, MaxConsumers and default_max_consumers.
Observability
Section titled “Observability”| Variable | Description | Default |
|---|---|---|
IRONFLOW_OTEL_ENDPOINT | OTLP gRPC endpoint for OpenTelemetry tracing. | (None, disabled) |
IRONFLOW_OTEL_SAMPLE_RATE | Trace sampling rate (0.0 to 1.0). | 1.0 |
IRONFLOW_OTEL_SERVICE_NAME | OTel service name. | ironflow |
IRONFLOW_OTEL_INSECURE | Use plaintext gRPC for OTLP export. | true |
IRONFLOW_METRICS_ENABLED | Enable Prometheus metrics at /metrics. | false |
Profiling
Section titled “Profiling”The --pprof CLI flag on ironflow serve starts a separate listener on 127.0.0.1:6060 (loopback only) with Go’s net/http/pprof handlers for CPU, heap, and goroutine profiling. Not configured via environment variable — CLI flag only. See Benchmarks for the load testing workflow.
Clustering
Section titled “Clustering”| Variable | Description | Default |
|---|---|---|
NATS_URL | External NATS server URL. When set, connects to external NATS instead of embedded. Requires PostgreSQL. | (None, embedded) |
NATS_CREDS_FILE | NATS credentials file for NKey/JWT auth. | (None) |
IRONFLOW_NODE_ID | Unique node identifier for distributed coordination. Must be stable per node. | Random UUID v4 |
IRONFLOW_STALE_CLAIM_THRESHOLD | Duration before stale scheduler claims are reclaimed. Covers orphaned sleep/wait wake-ups only — it does not control how fast a crashed worker’s run is recovered. That path is gated on concurrency-lease expiry, which is not configurable. The --dev flag drops it to 45s. See Crash recovery. | 2m |
IRONFLOW_STALE_CLAIM_RECOVERY_INTERVAL | How often the scheduler scans for orphaned claims. Lower values detect crashes faster at the cost of more frequent SQL sweeps. The --dev flag drops it to 5s. | 60s |
IRONFLOW_CLUSTER_ID | Seeds the database cluster identity on first startup. Thereafter asserts equality with the stored identity; a mismatch refuses execution startup. Audit events use the stored identity. | Stored identity, generated on first startup |
IRONFLOW_MIGRATION_LOCK_WAIT | How long a booting node waits for the PostgreSQL schema migration lock before it exits with timed out waiting for the schema migration advisory lock. A Go duration, such as 30m. Raise it when one migration takes longer on a large database, so the other nodes wait instead of restarting. Read with and without -f file.yaml. See PostgreSQL across hosts. | 5m |
NATS_FILE_STORAGE | Set to true to create file-backed JetStream streams instead of memory streams. Embedded NATS with a store directory (the default, see Embedded NATS storage) is file-backed regardless of this flag. The Starter, Cluster, HA, and multi-node Compose files set it to true. | false |
NATS_STREAM_REPLICAS | Replica count for clustered NATS: the JetStream streams and every Ironflow system KV bucket (SYS_*), plus KV buckets created after startup. Must not exceed the NATS cluster size. | 1 |
Multi-node clustering requires PostgreSQL. SQLite is single-node only — it supports
crash-resume for a restarted process but not multi-node coordination (#613). Every
variable in this table except IRONFLOW_CLUSTER_ID and IRONFLOW_MIGRATION_LOCK_WAIT is on the flags-only list: with
-f file.yaml set them as spec.nats.* / spec.cluster.* fields instead.
Security
Section titled “Security”| Variable | Description | Default |
|---|---|---|
IRONFLOW_WS_ALLOWED_ORIGINS | Comma-separated allowed origins for WebSocket connections. When empty, all origins are allowed, except on a /ws socket that impersonates an organization: there the Origin host and port must match the Host the server receives, so a reverse proxy must forward Host with its port (nginx: $http_host), or you set this variable (Impersonating Tenants). When set, a request with a non-matching Origin header is refused; a request with no Origin header (the Go, Node and Python SDKs, KV and config watches) is still admitted, because only browsers send Origin. | (None, all origins) |
IRONFLOW_AGENT_TOOLS_ALLOW_PRIVATE | When true, agent tool callback URLs may target RFC1918/loopback addresses. The --dev flag forces this on regardless. The engine checks the address when it registers the tool and again when it connects, so a hostname or a redirect that leads to a private address is refused too. | false |
IRONFLOW_PUSH_BLOCK_PRIVATE | When true, push dispatch refuses a function endpoint that resolves to a loopback or private address. Set it when the people who register functions must not reach the engine’s own network. Link-local addresses, which include the cloud metadata service (169.254.169.254), are refused in every configuration. If outbound requests go through an HTTP proxy on a private address, the connection to the proxy is refused too. | false |
IRONFLOW_AGENT_TOOLS_URL_ALLOWLIST | Comma-separated host allowlist for agent tool callback URLs. When set, only listed hosts are accepted. | (None, no allowlist) |
IRONFLOW_REQUIRE_ENCRYPTION | When true/1/yes/on, the engine verifies the SQLite data path lives on a dm-crypt block device and fails fast at startup otherwise. Postgres backends skip the check. Belt-and-suspenders against misconfigured systemd BindsTo units. | false (off) |
Engine
Section titled “Engine”| Variable | Description | Default |
|---|---|---|
IRONFLOW_CANCEL_ON_REPLAY_WINDOW_SECONDS | Lookback window (seconds) for the cancel-on-replay buffer. Recent events newer than this are kept for cancellation matching. The cancelOn reconcile pass also reads stored events from this many seconds before a run was created, and after each complete read it reads the last 30 seconds plus this window again, so a larger value raises its read load. | 30 |
IRONFLOW_CANCEL_ON_REPLAY_BUFFER_SIZE | Maximum entries retained in the cancel-on-replay buffer. Bounds memory under high event rates. | 10000 |
IRONFLOW_SQLITE_MAX_BYTES | Caps total SQLite footprint (main + WAL + SHM). At the cap, REST writes return 507 Insufficient Storage; unary RPC mutations return failed_precondition with Retry-After: 30. Deliberately not unavailable — the SDKs classify that retryable, and a full disk is permanent until an operator acts. SQLite backends only. Off unless set (#2113). | 0 (disabled) |
IRONFLOW_WRITE_QPS_MAX | Caps REST writes and unary RPC mutations per second per node. Excess writes return 429 / resource_exhausted with Retry-After: 1. Applies on every backend, PostgreSQL included — unlike the disk cap above, which is SQLite-only. One bucket per node, not per cluster. Off unless set (#2113). | 0 (disabled) |
Cloud Operator
Section titled “Cloud Operator”| Variable | Description | Default |
|---|---|---|
IRONFLOW_CAP_TOKEN_PRIVATE_KEY_B64 | Base64-encoded PKCS8 DER Ed25519 private key for signed staff provision and deprovision capability tokens. ironflow-cloud requires it at startup whenever IRONFLOW_CLOUD_ENGINE_URL is configured. Keep it on the control plane. The standalone ironflow cloud cap-token mint command also requires it. | (None) |
IRONFLOW_CLOUD_WORKER_API_KEY | Explicit environment-bound engine key for Cloud publishing and run inspection. Required at ironflow-cloud startup whenever IRONFLOW_CLOUD_ENGINE_URL is configured, including HTTP-only deployments. The publisher does not fall back to IRONFLOW_API_KEY. The developer role supplies event emission and run inspection. | (None) |
IRONFLOW_MASTER_KEY_B64 | Base64-encoded 32-byte Cloud control-plane KEK for decrypting break-glass bearer blobs and snapshot exports (ironflow cloud break-glass, ironflow cloud snapshot decrypt). | (None, required for decrypt) |
Auth Audit Batcher
Section titled “Auth Audit Batcher”| Variable | Description | Default |
|---|---|---|
IRONFLOW_AUDIT_BATCH_SIZE | Maximum rows per batch insert into the policy_decisions audit table. Not read yet: no code writes policy_decisions rows. | 100 |
IRONFLOW_AUDIT_BATCH_FLUSH_MS | Milliseconds between batch flushes. Set lower for tighter audit latency; set higher for write throughput. Not read yet: no code writes policy_decisions rows. | 1000 |
IRONFLOW_AUDIT_KV_EMIT | Set to true/1/yes/on to record KV bucket + key CRUD in the audit table. Off by default so hot-path KV writes do not bloat the audit table. | false |
IRONFLOW_AUDIT_RETENTION_DAYS | Days of audit rows kept by the nightly pruner. 0 disables pruning; values between 1 and 6 are rejected at startup (7-day safety floor). | 90 |
IRONFLOW_AUDIT_PAYLOAD_REDACT_FIELDS | Comma-separated exact <event_type>.<field> selectors from the workflow payload catalog. Values are trimmed and sorted. Unknown, malformed, duplicate, nested, wildcard, and protected-family selectors fail startup. Changes require a restart. | (None, full payload capture) |
IRONFLOW_AUDIT_AUTHZ_CAPTURE_SUCCESSFUL_READS | Set to a boolean. false suppresses authorization audit rows for allowed decisions on actions ending in :read or :list. An unparseable value is fatal at boot. Changes require a restart. | true (capture all) |
IRONFLOW_AUDIT_AUTHZ_EXCLUDE_ACTIONS | Comma-separated exact RBAC actions (e.g. functions:list,runs:read) whose allowed authorization decisions are not recorded. Entries are trimmed; empty entries are ignored. Changes require a restart. | (None, exclude nothing) |
The last two apply to allowed decisions only. A denial is always recorded,
whatever either variable is set to. GET /api/v1/audit/settings returns the
policy this process actually loaded.
Data-Plane Retention
Section titled “Data-Plane Retention”The same nightly pruner that trims audit rows also prunes runs, events and deleted entity
streams’ events. All three windows are off by default, share the 7-day safety floor, and are
read once at startup. Every statement the pruner issues is index-backed: both event sweeps
read events through an index on created_at, and the run sweep’s cascades and its
invoked-child cleanup each seek an index rather than scanning. Enabling a window no longer
costs a full table scan per batch. Building those indexes does: on
PostgreSQL they are created CONCURRENTLY at startup, the first time a node reaches that
migration, and migrations hold a cluster-wide lock for their duration — so on a large
existing events table every other node’s boot waits for the build. Already-running nodes
keep serving. Take that upgrade during a quiet window; turning a window on afterwards needs
no such care.
| Variable | Description | Default |
|---|---|---|
IRONFLOW_RUN_RETENTION_DAYS | Days of terminal runs (completed, failed, cancelled) kept by the nightly pruner, measured from the run’s created_at. Steps and the run’s other foreign-key children go with it, and dead letters older than the same cutoff are pruned in the same pass. 0 keeps runs forever; values between 1 and 6 are rejected at startup (7-day safety floor). Changes require a restart. | 0 |
IRONFLOW_EVENT_RETENTION_DAYS | Days of non-entity events kept by the nightly pruner, measured from the event’s created_at. An event still referenced by a run, an undelivered outbox row, a pending debounce fire, or an outbox dead letter is never pruned by this setting, and entity-stream events have their own window (IRONFLOW_ENTITY_EVENT_RETENTION_DAYS). 0 keeps events forever; 7-day floor. Changes require a restart. | 0 |
IRONFLOW_ENTITY_EVENT_RETENTION_DAYS | Days of entity-stream events kept on streams that carry a $stream.deleted tombstone, measured from the event’s created_at. Only rows below the tombstone are pruned; the tombstone and every event on a live stream stay. Rows still referenced by a run, an undelivered outbox row, a pending debounce fire or an outbox dead letter are skipped, and so is an accepted event whose triggers have not all started, however old, until it is dispatched or the stream is purged. 0 keeps them forever; 7-day floor. Changes require a restart. | 0 |
A short event window over a long run window is allowed, and partly deferred. Setting
IRONFLOW_EVENT_RETENTION_DAYS=7 alongside IRONFLOW_RUN_RETENTION_DAYS=90 — drop the
payloads early, keep the run records — starts, and the server logs a warning at boot.
The deferral is the four NOT EXISTS guards in the table above: an event still referenced
by a run, an undelivered outbox row, a pending debounce fire or an outbox dead letter is
pinned by that row and is not deleted until the row clears. Events that never produced
one (no matching trigger, a filtered emit) are pruned on their own window as configured.
The same holds for IRONFLOW_ENTITY_EVENT_RETENTION_DAYS.
The event window is a floor, not a guarantee, and only one of the four pins is on a
clock. Runs clear at the run window (the runs job goes first on the same nightly tick),
and outbox and debounce rows clear as they drain, so those three make the effective window
at least max(event window, run window). An outbox dead letter does not expire at all —
nothing prunes outbox_dead_letter on a timer. It is released only by an operator redrive
or discard (ironflow outbox dlq ...), by deleting the environment, or by the
entity-stream tombstone purge. While such a row names an event, that event is retained
indefinitely, whatever the window says. (The pruner’s own dead_letters job is a
different table and does not pin events.)
So if the short window is a privacy requirement rather than a disk-space one, do not rely on the event prune alone: pair it with a run window no longer than it, keep the outbox DLQ drained, and redact the payload (payload redaction) for the guarantee.
The event-router / projection dead-letter store has no window of its own: it rides on
IRONFLOW_RUN_RETENTION_DAYS. Setting a run window therefore also starts deleting
dead_letters rows older than the same cutoff — including entries you may be mid-triage
on, and this table is surfaced only as a count on the dashboard overview, so their
disappearance is silent. Export what you still need before you enable run retention.
(The outbox dead-letter queue behind ironflow outbox dlq is a different table and is
not pruned by any window.)
The pruner skips a terminal run while a non-terminal run’s step still names it as the child of an invoke. Otherwise a parent waiting on a lost result would outlive the child that holds it, and no re-resume could recover. Both become eligible once the parent reaches a terminal status of its own.
A parent that is wedged in waiting permanently never reaches one, so it and its child
are retained past every window, indefinitely, and nothing logs or counts what was
skipped. The size of that set is the number of wedged parents, not a function of traffic,
so it does not grow with load — but if a retention window is not reclaiming as much as you
expect, stuck parents are the first thing to check. The same skip applies to the bulk
DeleteRuns RPC, so a filter whose every remaining match is pinned answers 0 — which
that RPC otherwise documents as “drained” (on PostgreSQL, 0 can also mean that other
calls held the rows of every remaining match at that moment). Deleting one run by id (DeleteRun) bypasses
the skip, which is the manual escape: it is an explicit choice, and the parent can
re-invoke a fresh child afterwards.
The pruner also skips a terminal run whose triggering event is not marked processed.
That mark is the record that the event finished dispatching to every function it matched,
and the run row itself is what stops a late retry re-running a function that already
completed — an idempotency-key retry or a NATS redelivery re-enters dispatch and relies on
the run being there. A run whose dispatch never finished is therefore retained past every
window, indefinitely, and again nothing logs or counts it — along with its steps and the
event it pins. The set is mostly bounded by dispatch failures rather than by traffic —
mostly, because recording the mark is best-effort, so a database blip right after a
successful dispatch pins that run too. DeleteRun by id is the same manual escape. Upgrading applies a one-time backfill so cron and invoke runs
recorded before this behaviour existed are not all pinned by it.
All three windows share a 100-year ceiling as well as the 7-day floor. The ceiling is a safety
guard, not a limit anyone needs: the cutoff is computed as a time.Duration, which
overflows past roughly 106,752 days and would otherwise put the cutoff in the future.
A value above the ceiling is rejected at startup, like a value below the floor.
Event retention and projection rebuilds. A managed projection is rebuilt by replaying
the events table, filtered by event name — not by entity. So a projection registered
over ordinary emitted events reads exactly the rows IRONFLOW_EVENT_RETENTION_DAYS
deletes, and the newest event that prune has deleted from a filter is the furthest back such
a projection can be rebuilt.
The event prune records each deleted event name with its highest event sequence. A rebuild
whose replay range reaches the record of a name its filter matches is refused rather than
run — including --dry-run, which reports the refusal instead of an estimate. The record stays
when the window changes, so raising or unsetting IRONFLOW_EVENT_RETENTION_DAYS does not lift
the refusal: the events are still gone. A rebuild with no --from starts at the beginning of
history, so that covers every full rebuild of a projection whose filter has lost an event. A
filter that has lost none rebuilds whatever its age or window. The error names the projection
and, when one exists, an event to rebuild from. Run --from <event-id> with that event to
accept the shortened history and proceed. An event deleted another way, or pruned by a release
before this record existed, leaves no record and is not refused. The record is not reset when
the NATS stream is recreated. A full rebuild stays refused, and --from works only with an event
whose new sequence is above the record.
The same record guards a rebuild that is running. A prune that takes an event the rebuild has not read
fails the rebuild. An SDK projection’s runner then gets no events until a rebuild succeeds, and a read of its
state is refused. A SQL projection goes to error, applies no live event and answers
queries with failed_precondition. A prune of events the rebuild already read, or
older than its --from, does not stop it. A prune of another partition’s events does fail a --partition
rebuild, because the record has no partition. A window that catches up with a long full rebuild can fail it
too, so an SDK projection with events near the horizon may need --from. A SQL projection cannot use --from,
and recovers by being deleted and created again.
A projection whose filter matches only entity events is unaffected — entity events are
pruned only by IRONFLOW_ENTITY_EVENT_RETENTION_DAYS, and only on streams an operator has
deleted. That prune writes no record, so a rebuild of such a projection simply leaves the
deleted streams out.
If the record shows a loss and the environment holds no published event, --from has nothing to name. A projection with
state, a cursor above 0, a committed event or a recorded error is refused, because the rebuild would
discard its read model and report success. Recover by deleting the projection and registering it
again, which discards the read model and reads the PUBSUB stream from its first retained event, so
the result can still miss older history. A SQL projection cannot use --from in any case and recovers
the same way; an external one needs its own store cleared first. A projection with none of those
has nothing to lose, and its rebuild completes with no work.
Event Schema Enforcement
Section titled “Event Schema Enforcement”| Variable | Description | Default |
|---|---|---|
IRONFLOW_EVENT_SCHEMA_ENFORCEMENT | Whether emitted event payloads are validated against the event schema registry. off performs no lookup; warn logs a mismatch and accepts the event; reject refuses it with 400 and writes nothing. In reject, a failed registry read refuses the event with 503 rather than accepting it unvalidated — see enforcement failure modes. An unrecognized value refuses to start the server. | off |
Enforcement is off by default because warn cannot short-circuit — it must read
the registry to know whether to warn — so any other default would add a
synchronous query to every emit, including on the many deployments that have
never registered a schema. Nothing registers one automatically.
Validation applies to events entering through the HTTP and RPC emit paths, whose request context carries an environment.
It does not apply to engine-generated events (cron ticks,
ironflow.function.invoked), which never carry a user-registered schema.
It does apply to the NATS ingest stream (#1986). Registry lookups are
environment-scoped, and that path runs on the server’s startup context with no
environment attached, so the environment is resolved per message from the
subject instead: the EVENTS stream subject is
ironflow.{projectName}.{env}.events.ingest.> and
ironflow.{projectName}.{env}.events.resume.>. A rejected event is TERMed, not
redelivered.
On subjects Ironflow publishes, {env} is the environment ID, which resolves
to exactly one environment. A publisher can also put the environment name
there. Environment names are unique per project and project names are unique
per organization, so a name pair identifies one environment only within an
organization. If a name pair matches zero environments, or matches more than
one because two organizations use the same project name, the message is
accepted unvalidated. Publish with the environment ID to avoid this. Validating one tenant’s payload
against another tenant’s schema would be worse than not validating: in reject
it is a denial with no retry.
A resolved pair is cached for the life of the process, so renaming an environment (or deleting one and reusing its name in another organization) needs a restart to take effect on this path. A pair that does not resolve is re-checked after 60 seconds, so an environment created after a publisher already started using its subject begins being enforced on its own.
Registering a schema does not fetch anything: external $ref resolution is
disabled, so a schema may only reference definitions inside its own document.
A payload is validated against the schema registered for its exact
(event_name, version, environment), never the latest version — so registering
a new version cannot retroactively invalidate emitters that have not migrated.
If an event name has no schema registered at any version, it passes freely:
that is what keeps enforcement inert for anyone who has registered nothing. But
once any version is registered, the name is governed, and an event carrying a
version with no schema is rejected rather than accepted. Without that rule
the version field — which is client-supplied on every path that carries one,
and which trigger matching never consults — would be a way to walk straight
past a registered schema.
Every emit path can now select a version (#1955). POST /ironflow.v1.IronflowService/Emit takes
an optional version field, TriggerSync takes one on both transports, and
webhook ingest reads it from the source’s schema_version — a third-party
sender cannot express an Ironflow schema version, so it is the operator’s
choice at the source rather than the payload’s. Omitting it anywhere means
version 1. One version covers every event name a webhook source emits.
Deleting the last registered version of a governed event name is a breaking
change. Because matching is exact and a governed name rejects unregistered
versions, registering order.placed v2 and then deleting v1 turns every emit
still carrying v1 into a 400 — on every emit path, with no way for the emitter
to fix it. Register the new version, migrate emitters onto it, and only then
delete the old one.
If a stored schema does not compile, the event is accepted and the failure is logged: schemas registered before this check existed were never parsed.
Projection Rebuild
Section titled “Projection Rebuild”| Variable | Description | Default |
|---|---|---|
IRONFLOW_REBUILD_BATCH_SIZE_SQLITE | Events per batch when rebuilding a projection against a SQLite-backed event store. | 500 |
IRONFLOW_REBUILD_BATCH_SIZE_POSTGRES | Events per batch when rebuilding a projection against a PostgreSQL-backed event store. | 1000 |
IRONFLOW_REBUILD_BATCH_PAUSE_MS | Milliseconds to pause between FULL rebuild batches (PG only). Useful for throttling large rebuilds. | 0 |
Projection Catch-Up Waits
Section titled “Projection Catch-Up Waits”wait-for-catchup calls hold a slot for the life of the wait. Two limits guard
them: a fixed node-wide ceiling of 2000, and a per-organization slice of it so
one tenant filling its share still leaves the rest of the node available to
everyone else. Over either limit the call returns 429 Too Many Requests.
Raise the per-organization limit on a single-tenant deployment that
legitimately runs more than a quarter of the ceiling in concurrent waits;
setting it to 2000 restores a node-wide-only limit. Values above the node
ceiling are clamped to it. Do not set it below 16: a batch wait reserves
one slot per item and rejects the whole batch if it cannot get them all, so
a limit under the 16-item batch maximum fails full batches regardless of load.
| Variable | Description | Default |
|---|---|---|
IRONFLOW_PROJECTION_WAIT_CAP_PER_TENANT | Per-organization ceiling on concurrent projection catch-up waits. Capped at the node-wide ceiling of 2000. | 500 |
Bootstrap Defaults
Section titled “Bootstrap Defaults”On the very first boot of a fresh database, Ironflow creates a default admin user with the email admin@ironflow.local and a randomly generated password. Both the admin API key and password are written once to a mode-0400 bootstrap key file named in the startup banner (fields key and dashboard_password), not printed to the console. Read the file, save both values, then delete it — see Bootstrap Key.
To set the admin key and the dashboard password yourself, use IRONFLOW_BOOTSTRAP_ADMIN_KEY and IRONFLOW_BOOTSTRAP_ADMIN_PASSWORD (flags-only path; ignored with -f). See Seed the key from a secret store.
SDK Configuration
Section titled “SDK Configuration”TypeScript (Browser)
Section titled “TypeScript (Browser)”import { ironflow } from "@ironflow/browser";
ironflow.configure({ serverUrl: "http://localhost:9123", auth: { apiKey: "ifkey_..." }, environment: "env_default",});Additional options: transport (“connectrpc” | “websocket”), reconnect, visibility, logger, timeout, retry (file uploads only). auth also takes a short-lived token, which suits browser code better than an API key.
TypeScript (Node.js)
Section titled “TypeScript (Node.js)”import { createClient } from "@ironflow/node";
const client = createClient({ serverUrl: process.env.IRONFLOW_SERVER_URL || "http://localhost:9123", apiKey: process.env.IRONFLOW_API_KEY,});Additional options: environment, timeout (ms, default 30000), onError (global error handler), retry (default 3 attempts). apiKey falls back to IRONFLOW_API_KEY.
API Payload Limits
Section titled “API Payload Limits”| Limit | Value | Applies To |
|---|---|---|
| Max Event Data | 512 KB | Payload of an emitted event. |
| Max Step Output | 1 MB | Data returned by a step.run. |
| Max Batch Size | 100 | Max events in a TriggerBatch call. |
| Wait Timeout | 30s | Default timeout for emit --wait. |
Event schema enforcement failure modes
Section titled “Event schema enforcement failure modes”IRONFLOW_EVENT_SCHEMA_ENFORCEMENT=reject treats two kinds of failure
differently, because they have different owners.
| What failed | Owner | warn | reject |
|---|---|---|---|
| Payload does not match the registered schema | The emitter | Logs, accepts | 400, nothing written |
| Event name has no registered schema | Nobody — the name is ungoverned | Accepts | Accepts |
| Registered schema will not compile | The operator who registered it | Logs, accepts | Logs, accepts |
| Registry read failed (store down, timeout, lock contention) | Infrastructure | Logs, accepts | 503, nothing written |
A schema that will not compile fails open on purpose: refusing a caller’s valid payload because a stored row is malformed puts the blame in the wrong place, and it would turn one bad registration into a total ingest outage.
A registry read that fails is the opposite case and fails closed in
reject. An operator who set reject asked for a guarantee, and a database
brownout must not quietly turn it off during exactly the incident that makes it
matter. The cost is real and worth stating: on SQLite, which has a small
connection pool and one writer, store contention under load that previously degraded
enforcement now refuses ingest with 503. The status is retryable by design —
emitters should back off, and the NATS ingest path redelivers rather than
dead-lettering.
An unrecognized value for the variable refuses to start the server rather than
falling back to off. Silently running unenforced because of a typo is the
worst of the available outcomes.
Dedicated metrics listener
Section titled “Dedicated metrics listener”With metrics enabled, YAML spec.observability.metrics.port: 9124 binds a dedicated listener on the server host. Its port must be 1–65535 and differ from the bound API port, including when the API selects an ephemeral port. Zero/unset retains API-port metrics. Disabled metrics opens no extra listener. Startup fails if either required listener cannot bind. The dedicated listener serves only /metrics; API GET/HEAD /metrics returns 404. ironflow serve --metrics-port sets the same port and wins over the file. There is no environment variable for it.
JWT signing source
Section titled “JWT signing source”Set spec.auth.jwtSecretFile, --jwt-secret-file, or (flags-only)
IRONFLOW_JWT_SECRET_FILE to an existing private raw hex file owned by the engine
user. The engine never creates or repairs an explicit file. Missing, malformed,
public, or symlink files refuse startup and validation. API rotation atomically
replaces this file before swapping the active key. Its directory must be writable
by the engine. Back up and restore this exact file with the database.
CLI flags override YAML; YAML ignores environment variables unless referenced as
${VAR}. jwtSecret and jwtSecretFile are mutually exclusive. PostgreSQL
requires one of them. Inline jwtSecret remains supported for externally managed
rotation, but the dashboard JWT rotation API returns HTTP 409 for this source.
Change the external secret and restart every member to rotate it. SQLite without
either setting retains its generated private file next to the database.
For multiple engine members, provision the same signing key on every member. A local file does not distribute rotations to peer processes; coordinate rotation and restart across members.