Node Flowdocs

Configuration

Every environment variable the server reads, with its type, default and effect.

Everything is environment variables, validated once at boot. A mistyped role, a missing database URL or a malformed JSON map fails the start rather than surfacing minutes later as a confusing runtime error — or worse, as a silent default: a cluster where every workflow starts and none progresses because no process took the decider role.

Failures are reported all at once, which matters more than it sounds: fixing misconfiguration one restart at a time is how a five-minute deploy becomes an hour.

Invalid environment configuration:
  - DATABASE_URL: DATABASE_URL is required
  - NODE_FLOW_JWT_SECRET: NODE_FLOW_JWT_SECRET must be at least 32 characters
  - NODE_FLOW_ROLES: unknown role(s): decidr (expected api, decider, poller)

The rule this follows: a setting whose wrong value is silently survivable gets no default.

How values are parsed

TypeAcceptedNotes
Booleantrue, false, 1, 0, yes, noAnything else fails the boot.
IntegerA decimal numberCoerced; must be a positive integer where noted.
CSVa,b,cTrimmed; empty entries dropped.
JSONA JSON object or arrayParsed and structurally validated. A malformed map is a boot failure, not a runtime surprise.

Required

Two variables have no default, and the server will not start without them.

VariableTypeWhy there is no default
DATABASE_URLstringA server that silently starts against localhost/postgres because the real URL was missing is worse than one that refuses to boot.
NODE_FLOW_JWT_SECRETstring, ≥32 charsA hardcoded fallback secret is the single most reliably exploited misconfiguration in this class of system, precisely because it works.

Core

VariableTypeDefaultEffect
NODE_ENVdevelopment | test | productiondevelopmentStandard Node environment.
PORTint 1–655353000HTTP port. The server binds 0.0.0.0.
DATABASE_URLstringRequired. libpq connection URI; every pg parameter works, including sslmode and sslrootcert.
DATABASE_MAX_CONNECTIONSpositive int20Pool ceiling per process. A decider holds one per in-flight evaluation.
DATABASE_MIGRATE_ON_BOOTbooleantrueRun pending migrations at boot. See the caveat below.
NODE_FLOW_ROLESCSV of api, decider, pollerapi,decider,pollerWhich loops this process runs. An invalid value fails the boot; an empty value means all three.

DATABASE_MIGRATE_ON_BOOT=false currently also skips first-install seeding and the two LISTEN/NOTIFY listeners that power long-poll wake-ups and the live execution stream. Both listeners degrade to backstop polls rather than breaking, but a brand-new database started this way gets no namespace and no administrator. See Self-hosting → running migrations.


First-install seeding

Creates the first namespace and administrator on a database that has no namespaces at all — not "no namespace by this name". That makes it idempotent in the only sense that matters: it acts once in the lifetime of a database, and every later boot is one SELECT that does nothing.

VariableTypeDefaultEffect
NODE_FLOW_SEEDbooleantrueTurn off where accounts are provisioned by an identity provider — an unexpected local administrator is then a finding rather than a convenience.
NODE_FLOW_SEED_NAMESPACEstringdefaultSlug of the first namespace.
NODE_FLOW_SEED_EMAILemailadmin@node-flow.devThe first administrator. Granted admin and platform:admin, since creating a second namespace needs a scope admin deliberately does not satisfy.
NODE_FLOW_SEED_PASSWORDstring, ≥12 charsnoneLeave unset in production.

NODE_FLOW_SEED_PASSWORD deliberately has no default. Unset, the seed generates 160 bits of entropy and prints it once, as a block, at boot — so no two installs share a credential. A default here would put the same password on every deployment that never changed it, which is the failure this arrangement exists to avoid. The development compose file sets it explicitly, which is the right place for a known-weak secret and the only place one belongs.


Authentication and tokens

VariableTypeDefaultEffect
NODE_FLOW_JWT_SECRETstring, ≥32 charsRequired. Signs service-account access tokens.
NODE_FLOW_JWT_ISSUERstringnode-flowThe iss claim.
NODE_FLOW_ACCESS_TOKEN_TTL_SECONDSpositive int3600Lifetime of an exchanged access token.
NODE_FLOW_MTLS_ENABLEDboolean-ishfalseRead a client certificate for mTLS authentication. Only meaningful when this process terminates TLS itself with a CA bundle.

NODE_FLOW_MTLS_ENABLED is parsed with a plain coercion rather than the strict boolean parser the other flags use, so any non-empty string is truthy — including the literal "false". To disable it, leave the variable unset or set it to the empty string.

Secrets

VariableTypeDefaultEffect
NODE_FLOW_SECRET_KEYSid:base64key[,id:base64key…]`` (empty)Master keys for sealing secrets, newest first. 32 raw bytes each.

Empty means this install stores no secrets and says so when asked to — rather than storing them in clear, which is the failure an optional encryption key invites. Two entries is what a rotation looks like in flight: the first seals, the rest still open what they sealed before.

openssl rand -base64 32
NODE_FLOW_SECRET_KEYS='k2:NEW_KEY,k1:OLD_KEY'

Workload identity (machines)

VariableTypeDefault
NODE_FLOW_OIDC_ISSUERSJSON array[]
[
  { "issuer": "https://token.actions.githubusercontent.com", "jwksUri": "https://token.actions.githubusercontent.com/.well-known/jwks", "audience": "node-flow" }
]

Each entry requires all three fields. Empty disables the mechanism entirely — an install that does not use workload identity should not be reachable by anything presenting a token from an IdP it never chose. The issuer is matched exactly; nothing looser is offered, because a prefix or hostname match is how an IdP nobody configured ends up able to mint tokens for this install.

SSO (humans)

VariableTypeDefault
NODE_FLOW_SSO_PROVIDERSJSON array[]
NODE_FLOW_SAML_PROVIDERSJSON array[]

OIDC providers require every one of name, issuer, clientId, clientSecret, authorizationEndpoint, tokenEndpoint, jwksUri, redirectUri, namespace; scopes is optional.

[
  {
    "name": "okta",
    "issuer": "https://acme.okta.com",
    "clientId": "0oa…",
    "clientSecret": "…",
    "authorizationEndpoint": "https://acme.okta.com/oauth2/v1/authorize",
    "tokenEndpoint": "https://acme.okta.com/oauth2/v1/token",
    "jwksUri": "https://acme.okta.com/oauth2/v1/keys",
    "redirectUri": "https://flows.example.com/v1/auth/sso/okta/callback",
    "namespace": "default",
    "scopes": "openid email profile"
  }
]

SAML providers require name, entryPoint, issuer, callbackUrl, namespace and idpCert (one PEM, or several during a rotation). Optional: emailAttribute, nameAttribute, privateKey, decryptionPvk, spCertificate, identifierFormat, signatureAlgorithm (sha256 or sha512), acceptedClockSkewMs.

Only the SP-initiated, HTTP-POST-binding SAML flow is supported, and that is a security decision rather than a gap: an IdP-initiated assertion arrives unsolicited, carries no InResponseTo, and therefore cannot be bound to the browser that is about to receive the session.

NODE_FLOW_OIDC_ISSUERS and NODE_FLOW_SSO_PROVIDERS are deliberately separate: one verifies a token a platform already minted, the other runs a browser login. Conflating them would mean a machine token could open a human session.


Payloads and blob storage

VariableTypeDefaultEffect
NODE_FLOW_PAYLOAD_THRESHOLD_BYTESpositive int262144 (256 KiB)Payloads larger than this are offloaded.
NODE_FLOW_BLOB_STOREfs | s3fsWhere offloaded payloads live.
NODE_FLOW_BLOB_ROOTstring.node-flow/blobsfs only. Relative paths resolve against the working directory.
NODE_FLOW_BLOB_S3JSON object{}s3 only.
# AWS — omit the credentials and the SDK reads the instance role or IRSA,
# so node-flow holds no long-lived keys.
NODE_FLOW_BLOB_S3='{"bucket":"nf-payloads","region":"eu-west-1","prefix":"prod/"}'

# MinIO and other S3-compatible endpoints
NODE_FLOW_BLOB_S3='{"bucket":"payloads","endpoint":"http://minio:9000","forcePathStyle":true,"accessKeyId":"…","secretAccessKey":"…"}'

Fields: bucket, region, endpoint, forcePathStyle, accessKeyId, secretAccessKey, prefix.

NODE_FLOW_BLOB_STORE=s3 without a bucket fails at boot with the rest of the configuration, instead of at the first payload large enough to offload — which could be days later.

fs assumes every replica sees the same disk. More than one node without a shared mount needs s3, or a task will eventually fail to read a payload another server wrote.


Outbound HTTP

VariableTypeDefaultEffect
NODE_FLOW_HTTP_ALLOW_PRIVATEbooleanfalseLet HTTP, HTTP_POLL, WEBHOOK and PARSE_DOCUMENT reach private, loopback and link-local addresses.
NODE_FLOW_HTTP_ALLOWED_HOSTSCSV``Hostnames always permitted, even with the guard on. Exact match.

This is a deployment setting rather than a task input, and that is the point: any workflow author could set the latter, and this is the control that stops a definition reaching cloud metadata or an internal admin panel. Turn it on only where every workflow author is trusted; prefer naming hosts.


System task limits

VariableTypeDefaultEffect
NODE_FLOW_SYSTEM_TASK_CONCURRENCYpositive int20System tasks one poller process runs at a time.
NODE_FLOW_INLINE_TIMEOUT_MSpositive int5000CPU budget for an INLINE script, enforced from inside the sandbox.
NODE_FLOW_INLINE_MEMORY_BYTESpositive int33554432 (32 MiB)Memory budget for an INLINE script.
NODE_FLOW_JQ_TIMEOUT_MSpositive int5000Budget for a jq program. Enforced by killing the thread it runs in, because jq cannot be interrupted.
NODE_FLOW_JQ_MAX_OUTPUT_BYTESpositive int1048576 (1 MiB)Bound on a jq result.

Worker protocol

VariableTypeDefaultEffect
NODE_FLOW_MAX_POLL_SECONDSpositive int30Upper bound on a worker's long-poll, so a held connection is never unbounded. A client's waitSeconds is clamped to this.
NODE_FLOW_DEFAULT_LEASE_SECONDSpositive int60Lease duration when a worker names none. A requested lease is clamped to NODE_FLOW_MAX_POLL_SECONDS × 60.

Databases for the JDBC task

VariableTypeDefaultEffect
NODE_FLOW_SQL_DATASOURCESJSON object: name → connection string{}Databases a JDBC task may reach, by name.
NODE_FLOW_SQL_STATEMENT_TIMEOUT_MSpositive int30000Enforced by the database, not by us.
NODE_FLOW_SQL_MAX_ROWSpositive int1000Bounds a result, which is stored, indexed and passed to the next task.
NODE_FLOW_SQL_DATASOURCES='{"reporting":"postgres://reader:pw@warehouse:5432/analytics","legacy":"postgres://…"}'

Empty (the default) disables the task entirely, so an install that needs none is not exposed at all. A definition that supplies its own connection string is refused — see System tasks → JDBC.


gRPC services

VariableTypeDefault
NODE_FLOW_GRPC_SERVICESJSON object: name → config{}
{
  "pricing": {
    "address": "pricing.svc:50051",
    "protoPath": "/etc/node-flow/pricing.proto",
    "package": "pricing.v1",
    "service": "Pricing",
    "tls": false,
    "includeDirs": ["/etc/node-flow/protos"]
  }
}

address, protoPath, package and service are all required; a missing one fails the boot. tls defaults to false, because most gRPC is mesh-internal.


Message brokers

VariableTypeDefaultRequired key per connection
NODE_FLOW_KAFKA_CLUSTERSJSON object{}brokers (a non-empty array)
NODE_FLOW_NATS_CONNECTIONSJSON object{}servers
NODE_FLOW_AMQP_CONNECTIONSJSON object{}url
NODE_FLOW_SQS_CONNECTIONSJSON object{}region
NODE_FLOW_REDIS_CONNECTIONSJSON object{}url

Connection names may use only letters, digits, _ and -. A connection missing its required key fails the boot rather than becoming a source that never connects.

NODE_FLOW_KAFKA_CLUSTERS='{"default":{"brokers":["kafka-1:9092","kafka-2:9092"]}}'
NODE_FLOW_NATS_CONNECTIONS='{"default":{"servers":"nats://nats:4222"}}'
NODE_FLOW_AMQP_CONNECTIONS='{"default":{"url":"amqp://guest:guest@rabbit:5672"}}'
NODE_FLOW_SQS_CONNECTIONS='{"default":{"region":"eu-west-1"}}'
NODE_FLOW_REDIS_CONNECTIONS='{"default":{"url":"redis://redis:6379"}}'

Sink and source naming

ConnectionEVENT sinkEvent-handler source
NATSnats:<name>:<subject>nats:<name>
AMQPamqp:<name>:<queue> or amqp:<name>:<exchange>/<routingKey>amqp:<name>
SQSsqs:<name>:<queue>sqs:<name>
Redis Streamsredis:<name>:<stream>redis:<name>
Kafka(not an EVENT sink) — use the KAFKA_PUBLISH task with cluster and topickafka:<name>

The configured sources of a running install are readable at GET /v1/ns/{ns}/event-handlers/sources.

Writing kafka:default:my-topic as an EVENT sink matches no handler at all: the event dead-letters, visibly. Kafka is published with KAFKA_PUBLISH, which names its cluster and topic as separate fields.

The broker client libraries are optional peers. An install that uses none never loads them.


SMTP, for the EMAIL task

VariableTypeDefault
NODE_FLOW_SMTP_TRANSPORTSJSON object: name → config{}
{
  "default": {
    "host": "smtp.example.com",
    "port": 587,
    "secure": false,
    "user": "…",
    "pass": "…",
    "from": "ops@example.com"
  },
  "staging": { "host": "localhost", "disabled": true }
}

Only host is required. secure: true is implicit TLS (port 465); port 587 negotiates STARTTLS and leaves it false. disabled: true makes the transport fail loudly rather than swallowing mail — a staging install that quietly drops mail teaches everyone that the task works, and the surprise arrives in production.

nodemailer is an optional peer, so an install that sends no mail never loads it.


Circuit breakers

VariableTypeDefault
NODE_FLOW_CIRCUIT_BREAKERJSON object{} (disabled)
NODE_FLOW_CIRCUIT_BREAKER='{"enabled":true}'
NODE_FLOW_CIRCUIT_BREAKER='{"enabled":true,"failureRatio":0.5,"minimumRequests":10,"windowMs":30000,"openMs":5000,"maxOpenMs":60000}'
FieldDefaultValidated as
enabledfalseboolean
windowMs30000positive number
minimumRequests10positive number
failureRatio0.5a fraction above 0 and at most 1
openMs5000positive number
maxOpenMs60000positive number

Shared by HTTP, HTTP_POLL, WEBHOOK and GRPC. See Execution controls → circuit breakers.


Browser access to the REST gateway

VariableTypeDefault
NODE_FLOW_GATEWAY_CORS_ORIGINSCSV of exact origins``

Empty means no CORS headers and therefore no cross-origin browser access — the safe default, because a gateway route runs a workflow and the wrong origin list is a way for someone else's page to do that with a visitor's credentials.

* is deliberately not special-cased: it cannot be combined with credentials anyway, and an install that means "anyone" should say so origin by origin, or put a proxy in front that owns the policy.

NODE_FLOW_GATEWAY_CORS_ORIGINS='https://app.example.com,https://admin.example.com'

Telemetry

Read directly rather than through the validated schema, because the SDK must start before anything else is imported.

VariableDefaultEffect
NODE_FLOW_OTEL_ENABLEDunsetExactly true turns OpenTelemetry on. Any other value leaves it off.
OTEL_SERVICE_NAMEnode-flowResource attribute.
NODE_FLOW_VERSION1.0.0Reported as the service version.
OTEL_EXPORTER_OTLP_ENDPOINTRead by the OTLP exporter itself.
OTEL_EXPORTER_OTLP_HEADERSLikewise.
every other OTEL_*Sampling, resource attributes, protocol — all the standard names.

Everything except the on/off switch uses the standard OTEL_* variables the SDK already reads, so there is no second vocabulary for things that already have names.


The dashboard image

The UI is a separate container and reads only these.

VariableDefaultEffect
NODE_FLOW_API_URLhttp://localhost:3000Where the dashboard's server-side proxy sends API calls. The browser only ever talks to one origin, which is what keeps the session cookie working without CORS.
NODE_FLOW_UI_NAMESPACEdefaultThe namespace pre-filled on the login form.
PORT3100
HOSTStandard Next.js bind address.

The nf CLI

Flags win over environment variables.

VariableFlagUsed by
NF_URL--urlEverything that talks to a server. Defaults to http://localhost:3000.
NF_API_KEY--api-keyLikewise. No default — the command fails naming it.
NF_NAMESPACE--namespaceLikewise. Defaults to default.
DATABASE_URL--database-urlnf bootstrap, nf create-user, nf migrate, which talk to Postgres directly.

The benchmark harness (nf-bench) reads NODE_FLOW_API_KEY.


A production starting point

# ---- required ----
DATABASE_URL='postgres://nodeflow:…@db.internal:5432/nodeflow?sslmode=verify-full&sslrootcert=/certs/ca.pem'
NODE_FLOW_JWT_SECRET='…at least 32 characters…'

# ---- identity ----
NODE_FLOW_SECRET_KEYS='k1:…base64 of 32 random bytes…'
# Leave NODE_FLOW_SEED_PASSWORD unset. Set NODE_FLOW_SEED=false once the
# install has its accounts.

# ---- roles: one of these per deployment ----
NODE_FLOW_ROLES=api
# NODE_FLOW_ROLES=decider
# NODE_FLOW_ROLES=poller

# ---- storage ----
NODE_FLOW_BLOB_STORE=s3
NODE_FLOW_BLOB_S3='{"bucket":"nf-payloads","region":"eu-west-1"}'

# ---- safety ----
NODE_FLOW_HTTP_ALLOW_PRIVATE=false
NODE_FLOW_HTTP_ALLOWED_HOSTS='internal-api.svc.cluster.local'
NODE_FLOW_CIRCUIT_BREAKER='{"enabled":true}'
NODE_FLOW_GATEWAY_CORS_ORIGINS=''

# ---- capacity ----
DATABASE_MAX_CONNECTIONS=20
NODE_FLOW_SYSTEM_TASK_CONCURRENCY=20
NODE_FLOW_MAX_POLL_SECONDS=30
NODE_FLOW_DEFAULT_LEASE_SECONDS=60

# ---- migrations: run them as a job, not on every pod ----
DATABASE_MIGRATE_ON_BOOT=false

# ---- observability ----
NODE_FLOW_OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_SERVICE_NAME=node-flow

Next

On this page