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
From a checkout of the repository:
docker compose -f docker/docker-compose.yml upThat 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.
If you only want the published images and not this repository, see 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 docker/docker-compose.yml 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.
