# Getting Started (/docs/getting-started)



This guide takes you from a clean checkout to a running engine that does its
actual job. Ductor clears work through one lifecycle — **priced → routed →
executed → settled → proven** — and this quickstart is the shortest path to its
load-bearing middle: you'll bring up the stack, mint a tenant and API keys,
define a small two-step workflow, trigger it, watch it complete, and then kill
the process mid-run and watch the run pick up where it left off. By the end
you'll have proven the **executed** stage — durable execution — on your own
machine; the pricing, routing, and settlement stages build on the same engine.

<RuntimeSummary />

<Callout type="info" title="This page is the journey; Run Ductor locally is the reference">
  This guide gets you to your first running (and crash-resumed) workflow. For the
  compose internals, the bare-binary path (`ductor dev` / `serve`), service names,
  ports, and the full `DUCTOR_*` environment reference, see
  [Run Ductor locally](/docs/guides/run-locally).
</Callout>

## Prerequisites [#prerequisites]

* **Docker** and **Docker Compose** — the fastest path to a full stack.
* **`curl`*&#x2A; and &#x2A;*`jq`** — to drive the API and read JSON responses.

The compose stack brings up everything else for you: TimescaleDB (Postgres 16),
Dragonfly (a Redis-compatible cache), and Ductor itself. The production image is
a static, CGO-disabled Go binary on Alpine that runs as a non-root user — there's
nothing else to install on the host.

## Bring it up [#bring-it-up]

<Steps>
  <Step>
    **Run the stack.** Clone the repo, check out a release, and start the core
    services. Pin to a tagged release rather than tracking `master` so you know
    exactly what you're standing up. The commands below use `v1.8.0` as a concrete
    example; choose the release you have validated from the
    [releases page](https://github.com/ductor-io/ductor/releases). `make up` always
    rebuilds the Ductor image and stamps the checked-out git version into it, so you
    can never silently run a stale binary:

    ```bash
    git clone https://github.com/ductor-io/ductor.git
    cd ductor
    git checkout v1.8.0   # example pin; replace with the release you have validated
    make up
    ```

    Under the hood this is `docker compose up -d --build`, bringing up TimescaleDB,
    Dragonfly, and Ductor together. (There's also `make up-full` if you want the
    optional observability stack — VictoriaMetrics, VictoriaLogs, VictoriaTraces, and
    Grafana.)

    Ductor reads configuration from `configs/ductor.yaml`, and every key can be
    overridden with a `DUCTOR_` environment variable (dots become underscores). The
    compose file already wires the database and cache URLs and enables
    `DUCTOR_DATABASE_AUTO_MIGRATE`, so **migrations run automatically on boot** —
    there's no separate migrate step for the compose path.

    <Callout type="warn" title="The encryption key is not optional in production">
      `DUCTOR_CONNECTOR_ENCRYPTION_KEY` is a base64 32-byte key that decrypts every
      stored connector credential at rest. The compose stack runs fine without it
      for this walkthrough (no connectors), but for anything real, inject it from
      your secret manager, never commit it, and rotate it deliberately. See
      [Connectors](/docs/concepts/connectors).
    </Callout>
  </Step>

  <Step>
    **Wait for it to become healthy.** The gateway serves liveness on `/health` and
    readiness on `/ready`. Block until the process is up:

    ```bash
    until curl -fsS http://localhost:8080/health >/dev/null 2>&1; do sleep 2; done
    ```

    Confirm exactly what you're running — the `version` subcommand prints the stamped
    release tag and build time straight out of the binary:

    ```bash
    docker compose exec -T ductor /usr/local/bin/ductor version
    # version=v1.8.0 build_time=2026-01-15T12:00:00Z
    ```

    `/health` is a pure liveness probe. `/ready` runs the same dependency checks as
    the Connect `Ready` RPC, so a `200` there means Postgres and Dragonfly are
    reachable and the process is ready to serve. Use whichever your own probes
    expect. See [Health checks](/docs/operations/health-checks).

    By default Ductor serves REST (via grpc-gateway) **and** Connect-RPC on `:8080`,
    gRPC on `:50051`, and Prometheus metrics on `:9090`.

    <Callout title="The workflow runtime is on by default">
      The DAG coordinator is controlled by `workflow_runtime.enabled`, which
      defaults to `true` — durable workflow execution is running from first boot,
      nothing to switch on. For a routing-only deployment, opt out with
      `DUCTOR_WORKFLOW_RUNTIME_ENABLED=false`. See the
      [Configuration reference](/docs/reference/configuration).
    </Callout>
  </Step>
</Steps>

## Mint a tenant and API keys [#mint-a-tenant-and-api-keys]

Every tenant-scoped call needs a tenant. In the compose stack **auth is disabled**
(`DUCTOR_AUTH_ENABLED=false`, `DUCTOR_AUTH_ALLOW_ANONYMOUS=true`), so you don't
need a bearer token locally — but you still need a real tenant to scope your
requests to. The `seed-demo` subcommand mints that tenant, seeds
dashboard-visible demo data, and prints a set of API keys. It only needs
Postgres, and it's idempotent:

```bash
docker compose exec -T ductor /usr/local/bin/ductor seed-demo --reset --no-migrate
```

`--reset` wipes the demo tenants' data first for a clean run, and `--no-migrate`
skips migrations because the container already auto-migrated on boot. This seeds
two fixed-UUID tenants — `demo` and `acme` — and prints a summary like:

```text title="seed-demo output"
=== ductor seed-demo: done ===

Seeded tenants (use the UUID as the X-Tenant-ID / dashboard workspace id):
  - demo    11111111-1111-1111-1111-111111111111
  - acme    22222222-2222-2222-2222-222222222222

API keys (plaintext shown once — these are demo keys, safe to print):
  - tenant=demo   name=demo-admin     roles=admin      key=<printed once>
  - tenant=demo   name=demo-operator  roles=operator   key=<printed once>
  - tenant=demo   name=demo-readonly  roles=viewer     key=<printed once>
```

Capture the tenant UUID (and a key, if you want to try authenticated requests
later). The plaintext keys are printed **once** — the database only stores their
hashes:

```bash
export DUCTOR_TENANT_ID="11111111-1111-1111-1111-111111111111"
# Optional locally (auth is off). When you turn auth on, send the key you
# captured above as a bearer token:
# export DUCTOR_API_KEY="<demo-admin key from the seed-demo output>"
```

<Callout type="info" title="Use the seeded UUID as your tenant id">
  Workflow runs are stored against a UUID tenant column, so scope your calls
  with the seeded UUID rather than the label `demo`. The header name is literally
  `X-Tenant-ID`.
</Callout>

## Smoke test [#smoke-test]

Make one authenticated read to confirm the API is serving your tenant. Listing
workflow runs returns the demo runs `seed-demo` just created:

```bash
curl -s http://localhost:8080/api/workflows/runs \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" | jq '.data | length'
```

Two things to internalize from this call:

* REST paths are `/api/...` (and `/api/v2/...` for newer services), **not**
  `/v1/...`. The REST surface is generated from the same protobuf definitions as
  the Connect-RPC API, so anything you can do over gRPC you can do with plain
  JSON — all snake\_case.
* Every tenant-scoped call needs an `X-Tenant-ID` header. With auth enabled it
  must match your token's tenant; with the local stack's auth disabled, the
  header alone scopes the request.

<Callout type="info">
  Prefer typed clients? Point any Connect or gRPC client at `:8080` / `:50051`.
  The full request/response reference lives in the
  [API Reference](/docs/api-reference).
</Callout>

## Run your first workflow [#run-your-first-workflow]

Now make the engine do its job. You'll define a small two-step DAG that needs no
connectors — a `dataflow` step (a deterministic in-coordinator transform) feeding
a `wait` step (a durable delay) — then publish it, trigger a run, and poll it to
completion.

```mermaid
flowchart LR
  greet[greet: dataflow] --> pause[pause: wait 2s] --> done([completed])
```

<Steps>
  <Step>
    **Define a draft.** Create a workflow definition with two steps and one edge.
    Step types accept the protobuf enum name (`STEP_TYPE_DATAFLOW`) and edges accept
    `EDGE_TYPE_SUCCESS`; `args` is free-form JSON:

    ```bash title="1. Create the draft"
    BASE=http://localhost:8080
    DEF=$(curl -s -X POST $BASE/api/workflow-definitions \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" -H "Content-Type: application/json" -d '{
      "family_slug": "hello-world",
      "title": "Hello World",
      "steps": [
        {"ref": "greet", "type": "STEP_TYPE_DATAFLOW", "title": "Build greeting",
         "args": {"operation": "project", "project": {"message": "\"hello\""}}},
        {"ref": "pause", "type": "STEP_TYPE_WAIT", "title": "Short delay",
         "args": {"duration": "2s"}}
      ],
      "edges": [{"source_ref": "greet", "target_ref": "pause", "type": "EDGE_TYPE_SUCCESS"}]
    }')
    ID=$(echo "$DEF" | jq -r '.data.id')
    echo "definition: $ID"
    ```

    The `dataflow` step's `project` operation maps output fields to CEL expressions
    (here, the literal string `"hello"`). The `wait` step takes exactly one of
    `duration`, `duration_ms`, `until`, `cron`, or `until_expr`; `duration` is a Go
    duration string such as `"2s"` or `"5m"`.
  </Step>

  <Step>
    **Publish it.** A run can only be triggered from a published definition:

    ```bash title="2. Publish"
    curl -s -X POST $BASE/api/workflow-definitions/$ID/publish \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" >/dev/null
    ```
  </Step>

  <Step>
    **Trigger a run.** The trigger endpoint returns the new `run_id`:

    ```bash title="3. Trigger"
    RUN=$(curl -s -X POST "$BASE/api/v2/workflow-definitions/$ID:trigger" \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" -H "Content-Type: application/json" \
      -d '{"subject_type": "demo", "subject_id": "1", "input": {"name": "world"}}')
    RID=$(echo "$RUN" | jq -r '.run_id')
    echo "run: $RID"
    ```

    If you'd rather block until the run reaches a terminal checkpoint instead of
    polling, add `"wait_for_checkpoint": true, "checkpoint_timeout_seconds": 10` to
    the trigger body.
  </Step>

  <Step>
    **Poll it to completion.** The run moves `pending → running → completed`. Read the
    status, then inspect the per-step detail:

    ```bash title="4. Poll"
    curl -s $BASE/api/workflows/runs/$RID \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" | jq '.data.summary.status'

    curl -s $BASE/api/workflows/runs/$RID/steps \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" | jq
    ```

    Within a few seconds the status reads `"completed"`. You just ran a durable
    workflow end to end — defined over the API, coordinated by the runtime, no
    connectors required.
  </Step>
</Steps>

## Watch it survive a crash [#watch-it-survive-a-crash]

Durable execution means a run's progress lives in Postgres, not in process
memory — so killing the engine mid-run doesn't lose the run. Prove it by parking
a run on a longer `wait`, crashing the container, and watching it resume.

```mermaid
stateDiagram-v2
  [*] --> running
  running --> parked: wait (60s)
  parked --> killed: docker compose kill
  killed --> parked: docker compose up
  parked --> completed: delay elapses
  completed --> [*]
```

<Steps>
  <Step>
    **Publish a run with a long wait.** Reuse the two-step shape from above, but set
    the `wait` duration to `60s` so you have a comfortable window to crash it:

    ```bash title="Define, publish, and trigger a 60s-wait run"
    DEF=$(curl -s -X POST $BASE/api/workflow-definitions \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" -H "Content-Type: application/json" -d '{
      "family_slug": "durable-demo",
      "title": "Durable Demo",
      "steps": [
        {"ref": "greet", "type": "STEP_TYPE_DATAFLOW", "title": "Build greeting",
         "args": {"operation": "project", "project": {"message": "\"hello\""}}},
        {"ref": "pause", "type": "STEP_TYPE_WAIT", "title": "Long delay",
         "args": {"duration": "60s"}}
      ],
      "edges": [{"source_ref": "greet", "target_ref": "pause", "type": "EDGE_TYPE_SUCCESS"}]
    }')
    ID=$(echo "$DEF" | jq -r '.data.id')
    curl -s -X POST $BASE/api/workflow-definitions/$ID/publish \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" >/dev/null
    RID=$(curl -s -X POST "$BASE/api/v2/workflow-definitions/$ID:trigger" \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" -H "Content-Type: application/json" \
      -d '{"subject_type": "demo", "subject_id": "2"}' | jq -r '.run_id')
    echo "run: $RID"
    ```

    Confirm it's parked on the wait (status `"running"`):

    ```bash
    curl -s $BASE/api/workflows/runs/$RID \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" | jq '.data.summary.status'
    ```
  </Step>

  <Step>
    **Kill the engine, then bring it back.** While the run is still waiting:

    ```bash
    docker compose kill ductor
    docker compose up -d ductor
    until curl -fsS http://localhost:8080/health >/dev/null 2>&1; do sleep 2; done
    ```
  </Step>

  <Step>
    **Watch it resume and complete.** Poll again once the delay has elapsed:

    ```bash
    curl -s $BASE/api/workflows/runs/$RID \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" | jq '.data.summary.status'
    ```

    The status reaches `"completed"` — the restarted engine picked the run back up
    and finished it.
  </Step>
</Steps>

**Why this works.** A `wait` step is persisted with a durable wakeup, not an
in-memory timer, so a parked run is just a row waiting to be ticked. On restart,
the workflow runtime coordinator scans for runs due for a tick every
`poll_interval` (5s by default) and reclaims any run whose previous coordinator
lease went stale after `recovery_stale_after` (30s by default). It only picks up
non-terminal runs — completed runs are skipped — so nothing is re-executed twice.
Concretely, a cleanly-parked run resumes within roughly the poll interval, or up
to the stale-after window if the previous coordinator's lease has to expire
first.

## Next steps [#next-steps]

<Cards>
  <Card title="Understand the execution model" href="/docs/concepts/coordinator-workers">
    How the Coordinator and Step Workers divide responsibility, tick by tick.
  </Card>

  <Card title="Learn the workflow vocabulary" href="/docs/concepts/dag-workflow-model">
    Steps, edges, scatter/gather, sub-workflows, waits, and ContinueAsNew.
  </Card>

  <Card title="Route your first event" href="/docs/concepts/routing-pipeline">
    Walk the Validate → Enrich → Filter → Select → Assign pipeline.
  </Card>

  <Card title="See the architecture" href="/docs/architecture/layered-architecture">
    The layered design, data flow, and how it all fits together.
  </Card>
</Cards>

Going further: enable the inbound MCP server and in-product chat agent under
[AI & agents](/docs/ai), or define your workflows in code with the
[Go and Python SDKs](/docs/sdks) instead of authoring DAGs through the API.
