Getting Started

Stand up Ductor locally with Docker, seed a tenant, then define, run, and crash-resume your first durable workflow.

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.

You need
Docker, Compose, curl, and jq
You will build
A published two-step workflow
You will prove
The run resumes after a process restart

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.

Prerequisites

  • Docker and Docker Compose — the fastest path to a full stack.
  • curl and 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

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

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.

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.

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

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:

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.

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

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.

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:

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:

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:

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>"

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.

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:

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.

Prefer typed clients? Point any Connect or gRPC client at :8080 / :50051. The full request/response reference lives in the API Reference.

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.

greet: dataflow pause: wait 2s completed

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:

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

Publish it. A run can only be triggered from a published definition:

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

Trigger a run. The trigger endpoint returns the new run_id:

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.

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

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.

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.

wait (60s) docker compose kill docker compose up delay elapses running parked killed completed

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:

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"):

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

Kill the engine, then bring it back. While the run is still waiting:

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

Watch it resume and complete. Poll again once the delay has elapsed:

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.

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

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