Quickstart
From nothing to a completed workflow run, with a worker, in about a minute.
This walks from an empty directory to a workflow that has run, with a worker of your own doing one of its steps. Everything here is verified against the same image the release pipeline ships.
There is no nf bootstrap step any more. The server seeds a namespace and
an administrator on its first boot against an empty database, so starting it
is all it takes to have a working install.
1. Start the stack
Bring up Postgres, the engine and the dashboard
No clone and no build. Docker pulls the released images from the GitHub
Container Registry. Save this as docker-compose.yml in an empty directory:
# node-flow, from the published images — no clone, no build.
#
# Every secret below is a public development value, fine on a laptop and
# nowhere else. For a real install see /docs/guide/self-hosting.
name: node-flow
services:
postgres:
image: postgres:18-alpine
container_name: node-flow-postgres
restart: unless-stopped
environment:
POSTGRES_USER: nodeflow
POSTGRES_PASSWORD: nodeflow
POSTGRES_DB: nodeflow
POSTGRES_INITDB_ARGS: '--locale-provider=icu --icu-locale=en-US --encoding=UTF8'
ports:
# 5433, not 5432, so a Postgres you already run keeps its port.
- '${NODE_FLOW_POSTGRES_PORT:-5433}:5432'
volumes:
# Postgres 18 wants one mount at /var/lib/postgresql, not .../data.
- postgres-data:/var/lib/postgresql
command:
- postgres
- -c
- autovacuum_vacuum_scale_factor=0.02
- -c
- autovacuum_naptime=10s
- -c
- io_method=worker
- -c
- max_connections=200
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U nodeflow -d nodeflow']
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
# The engine: API, decider and poller in one container.
server:
image: ghcr.io/dsardar099/node-flow/server:1.0.0
container_name: node-flow-server
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgres://nodeflow:nodeflow@postgres:5432/nodeflow
NODE_FLOW_ROLES: api,decider,poller
NODE_FLOW_JWT_SECRET: development-secret-change-me-at-least-32-chars
NODE_FLOW_SECRET_KEYS: 'dev:bm9kZS1mbG93LWRldmVsb3BtZW50LWtleS1ub3Qtc2U='
# The first administrator, created on first boot. Leave the password
# unset anywhere real and one is generated and printed once instead.
NODE_FLOW_SEED_EMAIL: you@example.com
NODE_FLOW_SEED_PASSWORD: development-password
PORT: 3000
ports:
- '3000:3000'
healthcheck:
test: ['CMD-SHELL', 'node -e "fetch(\"http://localhost:3000/v1/health\").then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"']
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
# The dashboard. It calls the API server-side, so the browser only ever
# talks to one origin.
ui:
image: ghcr.io/dsardar099/node-flow/ui:1.0.0
container_name: node-flow-ui
restart: unless-stopped
depends_on:
server:
condition: service_healthy
environment:
NODE_FLOW_API_URL: http://server:3000
NODE_FLOW_UI_NAMESPACE: default
PORT: 3100
ports:
- '3100:3100'
volumes:
postgres-data:Then:
docker compose up -d
docker compose ps # all three should report "healthy" or "Up"The first run pulls about a minute's worth of images; later starts take seconds. The images are multi-architecture, so this works as-is on Intel and Apple Silicon.
Either way, that starts three containers:
The database is published on 5433, not 5432, so a Postgres you already run
keeps its port. Set NODE_FLOW_POSTGRES_PORT to move it. Nothing inside the
compose network is affected either way — the services reach each other on 5432
over the bridge.
To start again from nothing, docker compose down -v removes the containers
and the database volume; the next up seeds a fresh install. To move to a newer
release, change the two :1.0.0 tags and run docker compose up -d again —
migrations apply on boot.
This file is for trying node-flow. For an install you keep — your own secrets, a generated admin password, a managed Postgres — use Self-hosting → Docker Compose without this codebase.
Sign in
Open the dashboard at http://localhost:3100:
email you@example.com
password development-passwordThose credentials come from the compose file — either one — and exist to make this page copy-pasteable:
NODE_FLOW_SEED_EMAIL: you@example.com
NODE_FLOW_SEED_PASSWORD: development-passwordAnywhere real, leave NODE_FLOW_SEED_PASSWORD unset. The server then
generates 160 bits of entropy per install and prints it once, as a block, at
boot:
┌─────────────────────────────────────────────────────────────┐
│ node-flow created your first administrator. │
│ This password is shown once and is not recoverable. │
└─────────────────────────────────────────────────────────────┘
namespace default
email admin@node-flow.dev
password fQ3n8Zk1wRr7Tb0xLpYvSeeding acts only when the database contains no namespaces at all, so it
happens once in the lifetime of a database and every later boot is one
SELECT that does nothing. Set NODE_FLOW_SEED=false to turn it off entirely
— the right choice where accounts come from an identity provider.
Mint an API key
The dashboard is one way in; everything below uses the API, which needs a credential of its own. The easiest route is Admin → API keys in the dashboard. Over HTTP it is a login followed by a key request:
# Log in, keeping the session and CSRF cookies in a jar.
curl -s -c jar -X POST http://localhost:3000/v1/ns/default/users/login \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"development-password"}'
# Mint the key. Writes need the CSRF header, whose value is the nf_csrf cookie.
curl -s -b jar -c jar -X POST http://localhost:3000/v1/auth/api-keys \
-H 'content-type: application/json' \
-H "x-csrf-token: $(awk '$6=="nf_csrf" {print $7}' jar)" \
-d '{"name":"quickstart","scopes":["admin"]}'{
"id": "0193c0f1-…",
"prefix": "nf_abcd",
"token": "nf_abcd…",
"warning": "the token is shown once and cannot be recovered"
}The token is shown once. Keep it:
export NF_API_KEY=nf_...
export NF_URL=http://localhost:3000
export NF_NAMESPACE=default
curl -s -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/auth/whoami"
# { "type": "SERVICE_ACCOUNT", "id": "…", "name": "quickstart",
# "namespaceId": "…", "scopes": ["admin"] }nf bootstrap --namespace default does the same thing against Postgres
directly, which is handy in CI where there is no browser. It is no longer part
of the normal install — the server seeds its own first account.
2. Register a workflow
Save this as greet.json. It has one server-run step (INLINE, a sandboxed
JavaScript snippet) and one step of yours (SIMPLE, which a worker will do).
{
"name": "greet",
"version": 1,
"description": "Says hello, then asks a worker to shout it.",
"inputParameters": ["name"],
"tasks": [
{
"name": "compose",
"taskReferenceName": "compose",
"type": "INLINE",
"inputParameters": {
"expression": "return { greeting: 'hello ' + $.who };",
"who": "${workflow.input.name}"
}
},
{
"name": "shout",
"taskReferenceName": "shout",
"type": "SIMPLE",
"inputParameters": { "text": "${compose.output.greeting}" }
}
],
"outputParameters": { "message": "${shout.output.shouted}" }
}Register it:
curl -s -X POST "$NF_URL/v1/ns/$NF_NAMESPACE/metadata/workflows" \
-H "x-api-key: $NF_API_KEY" \
-H 'content-type: application/json' \
--data @greet.json3. Write the worker
shout is a SIMPLE task, which means nothing will run it until a process
leases it. That process is yours.
npm install @node-flow-dev/sdkimport { NodeFlowClient, Worker } from '@node-flow-dev/sdk';
const client = new NodeFlowClient({
baseUrl: 'http://localhost:3000',
namespace: 'default',
apiKey: process.env.NF_API_KEY,
});
const worker = new Worker({
client,
// The queue is the task definition name — `name`, not `taskReferenceName`.
queue: 'shout',
concurrency: 4,
waitSeconds: 30,
handler: async ({ input, log }) => {
log(`shouting: ${input['text']}`);
return { shouted: String(input['text']).toUpperCase() };
},
onError: (error) => console.error(error),
});
worker.start();
// Drain in-flight tasks on shutdown instead of abandoning their leases.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => void worker.stop().then(() => process.exit(0)));
}# Node 24 strips types natively, so no build step is needed.
node worker.ts4. Run it
nf run greet --input '{"name":"ada"}' --wait 300a1f... COMPLETED
{
"message": "HELLO ADA"
}The exit code is scriptable: 0 completed, 1 finished but not COMPLETED,
2 still running when the wait elapsed.
5. Watch it
nf tail 0193c0f1-...14:22:07 compose COMPLETED
14:22:07 shout SCHEDULED
14:22:08 shout IN_PROGRESS
14:22:08 shout COMPLETED
── workflow COMPLETEDOr read the whole run, tasks included:
curl -s -H "x-api-key: $NF_API_KEY" \
"$NF_URL/v1/ns/$NF_NAMESPACE/executions/0193c0f1-..." | jqOr open it in the dashboard, which follows the run over server-sent events at
/v1/ns/default/executions/{id}/stream and needs no polling.
6. Test it without a server
This is the part that most changes how it feels to work on workflows. nf test
runs a definition through the real engine in memory — same decider, same
expression resolution, same retry semantics — with external work mocked.
{
"definition": {
"name": "greet",
"version": 1,
"inputParameters": ["name"],
"tasks": [
{
"name": "compose",
"taskReferenceName": "compose",
"type": "INLINE",
"inputParameters": {
"expression": "return { greeting: 'hello ' + $.who };",
"who": "${workflow.input.name}"
}
},
{
"name": "shout",
"taskReferenceName": "shout",
"type": "SIMPLE",
"inputParameters": { "text": "${compose.output.greeting}" }
}
],
"outputParameters": { "message": "${shout.output.shouted}" }
},
"input": { "name": "ada" },
"mocks": {
"compose": { "output": { "greeting": "hello ada" } },
"shout": { "output": { "shouted": "HELLO ADA" } }
},
"expect": {
"status": "COMPLETED",
"output": { "message": "HELLO ADA" },
"tasks": { "shout": "COMPLETED" }
}
}nf test greet.test.json✓ greet.test.json COMPLETED 7 ms
1 passed, 0 failedExit code 1 on any failed expectation, so it drops straight into CI. See CLI → nf test for the full file format.
What to read next
- Concepts — what a reference name is, why queues are named
after task definitions, and how
${...}resolves. - Workflows — branching, parallelism, loops, sub-workflows, compensation.
- Workers — leases, heartbeats, idempotency, domains.
- Self-hosting — doing this somewhere that matters.
