# Quickstart

> From nothing to a completed workflow run, with a worker, in about a minute.

Source: https://node-flow.dev/docs/guide/quickstart

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.

<Callout type="info">
  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.
</Callout>

## 1. Start the stack

<Steps>
  <Step>
    ### Bring up Postgres, the engine and the dashboard [#bring-up-postgres-the-engine-and-the-dashboard]

    <Tabs items="['Published images', 'From a checkout']">
      <Tab value="Published images">
        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:

        ```yaml title="docker-compose.yml"
        # 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:

        ```bash
        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.
      </Tab>

      <Tab value="From a checkout">
        If you are working on node-flow itself, run the compose file in the repository,
        which builds the images from your working tree:

        ```bash
        git clone https://github.com/dsardar099/node-flow.git
        cd node-flow
        docker compose -f docker/docker-compose.yml up
        ```

        It sets the same ports and the same development credentials as the other tab,
        so everything below works identically.
      </Tab>
    </Tabs>

    Either way, that starts three containers:

    <Files>
      <Folder name="node-flow (compose project)">
        <File name="node-flow-postgres — postgres:18-alpine, published on host port 5433" />

        <File name="node-flow-server — the engine, api + decider + poller, on 3000" />

        <File name="node-flow-ui — the dashboard, on 3100" />
      </Folder>
    </Files>

    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](/docs/guide/self-hosting#docker-compose-without-this-codebase).
  </Step>

  <Step>
    ### Sign in [#sign-in]

    Open the dashboard at `http://localhost:3100`:

    ```
    email     you@example.com
    password  development-password
    ```

    Those credentials come from the compose file — either one — and exist to make
    this page copy-pasteable:

    ```yaml
    NODE_FLOW_SEED_EMAIL: you@example.com
    NODE_FLOW_SEED_PASSWORD: development-password
    ```

    <Callout type="warn">
      **Anywhere 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    fQ3n8Zk1wRr7Tb0xLpYv
      ```

      Seeding 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.
    </Callout>
  </Step>

  <Step>
    ### Mint an API key [#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:

    ```bash
    # 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"]}'
    ```

    ```json
    {
      "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:

    ```bash
    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"] }
    ```

    <Callout type="info">
      `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.
    </Callout>
  </Step>
</Steps>

## 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).

```json title="greet.json"
{
  "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}" }
}
```

<Mermaid
  title="greet v1"
  chart="`
flowchart LR
START(( )) --> COMPOSE[compose<br/>INLINE, server-run]
COMPOSE --> SHOUT[shout<br/>SIMPLE, your worker]
SHOUT --> END((( )))
`"
/>

Register it:

<Tabs items="['curl', 'nf CLI', 'TypeScript SDK']">
  <Tab value="curl">
    ```bash
    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.json
    ```
  </Tab>

  <Tab value="nf CLI">
    ```bash
    npx --yes @node-flow-dev/cli@1.0.0 workflows register greet.json
    ```

    `nf` reads `NF_URL`, `NF_API_KEY` and `NF_NAMESPACE` from the environment, or
    takes `--url`, `--api-key` and `--namespace`.
  </Tab>

  <Tab value="TypeScript SDK">
    ```ts title="register.ts"
    import { NodeFlowClient } from '@node-flow-dev/sdk';
    import definition from './greet.json' with { type: 'json' };

    const client = new NodeFlowClient({
      baseUrl: 'http://localhost:3000',
      namespace: 'default',
      apiKey: process.env.NF_API_KEY,
    });

    console.log(await client.registerWorkflow(definition));
    ```

    <Callout type="warn">
      `baseUrl` is the **origin**, not the versioned path. The client appends `/v1`
      itself, so `http://localhost:3000` is right and `http://localhost:3000/v1`
      produces requests to `/v1/v1/...`.
    </Callout>
  </Tab>
</Tabs>

## 3. Write the worker

`shout` is a `SIMPLE` task, which means nothing will run it until a process
leases it. That process is yours.

<Tabs items="['TypeScript SDK', 'Plain REST (any language)']">
  <Tab value="TypeScript SDK">
    ```bash
    npm install @node-flow-dev/sdk
    ```

    ```ts title="worker.ts"
    import { 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)));
    }
    ```

    ```bash
    # Node 24 strips types natively, so no build step is needed.
    node worker.ts
    ```
  </Tab>

  <Tab value="Plain REST (any language)">
    There is no SDK requirement. A worker is any process that can do three HTTP
    calls. This is the whole protocol:

    ```bash
    # 1. Lease. waitSeconds parks the request until work appears (long poll).
    LEASED=$(curl -s -X POST "$NF_URL/v1/ns/$NF_NAMESPACE/queues/shout/lease" \
      -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
      -d '{"workerId":"shell-1","count":1,"waitSeconds":30,"leaseSeconds":60}')

    TASK_ID=$(echo "$LEASED"  | jq -r '.tasks[0].taskId')
    WF_ID=$(echo "$LEASED"    | jq -r '.tasks[0].workflowId')
    TOKEN=$(echo "$LEASED"    | jq -r '.tasks[0].leaseToken')
    TEXT=$(echo "$LEASED"     | jq -r '.tasks[0].input.text')

    # 2. (Optional) heartbeat, if the work may outlive leaseSeconds.
    curl -s -X POST "$NF_URL/v1/ns/$NF_NAMESPACE/tasks/$TASK_ID/heartbeat" \
      -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
      -d "{\"queueName\":\"shout\",\"leaseToken\":\"$TOKEN\",\"leaseSeconds\":60}"

    # 3. Report. The lease token is the fencing token: a worker whose lease expired
    #    cannot write over whoever holds the task now.
    curl -s -X POST "$NF_URL/v1/ns/$NF_NAMESPACE/tasks/$TASK_ID/report" \
      -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
      -d "{\"queueName\":\"shout\",\"workflowId\":\"$WF_ID\",\"leaseToken\":\"$TOKEN\",\"status\":\"COMPLETED\",\"output\":{\"shouted\":\"$(echo "$TEXT" | tr a-z A-Z)\"}}"
    ```
  </Tab>
</Tabs>

## 4. Run it

<Tabs items="['nf CLI', 'curl', 'TypeScript SDK']">
  <Tab value="nf CLI">
    ```bash
    nf run greet --input '{"name":"ada"}' --wait 30
    ```

    ```
    0a1f...  COMPLETED
    {
      "message": "HELLO ADA"
    }
    ```

    The exit code is scriptable: `0` completed, `1` finished but not `COMPLETED`,
    `2` still running when the wait elapsed.
  </Tab>

  <Tab value="curl">
    Start and wait for the answer in one call:

    ```bash
    curl -s -X POST "$NF_URL/v1/ns/$NF_NAMESPACE/executions/greet/execute" \
      -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
      -d '{"input":{"name":"ada"},"waitForSeconds":30}'
    ```

    ```json
    {
      "workflowId": "0193c0f1-...",
      "reached": true,
      "status": "COMPLETED",
      "output": { "message": "HELLO ADA" }
    }
    ```

    Or start it and get a receipt:

    ```bash
    curl -s -X POST "$NF_URL/v1/ns/$NF_NAMESPACE/executions/greet" \
      -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
      -d '{"input":{"name":"ada"}}'
    ```

    ```json
    { "workflowId": "0193c0f1-...", "status": "RUNNING", "defName": "greet", "defVersion": 1 }
    ```
  </Tab>

  <Tab value="TypeScript SDK">
    ```ts
    const { workflowId } = await client.startWorkflow({
      name: 'greet',
      input: { name: 'ada' },
      idempotencyKey: 'greet-ada-1',
    });

    console.log(await client.executionStatus(workflowId));
    ```
  </Tab>
</Tabs>

## 5. Watch it

```bash
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 COMPLETED
```

Or read the whole run, tasks included:

```bash
curl -s -H "x-api-key: $NF_API_KEY" \
  "$NF_URL/v1/ns/$NF_NAMESPACE/executions/0193c0f1-..." | jq
```

Or 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.

```json title="greet.test.json"
{
  "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" }
  }
}
```

```bash
nf test greet.test.json
```

```
✓ greet.test.json  COMPLETED  7 ms

1 passed, 0 failed
```

Exit code 1 on any failed expectation, so it drops straight into CI. See
[CLI → nf test](/docs/guide/cli#nf-test) for the full file format.

## What to read next

* [Concepts](/docs/guide/concepts) — what a reference name is, why queues are named
  after task definitions, and how `${...}` resolves.
* [Workflows](/docs/guide/workflows) — branching, parallelism, loops, sub-workflows,
  compensation.
* [Workers](/docs/guide/workers) — leases, heartbeats, idempotency, domains.
* [Self-hosting](/docs/guide/self-hosting) — doing this somewhere that matters.
