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.
curlandjq— 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 upUnder 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; doneConfirm 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:
=== 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-IDheader. 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.
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:
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:
curl -s -X POST $BASE/api/workflow-definitions/$ID/publish \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" >/dev/nullTrigger a run. The trigger endpoint returns the new run_id:
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:
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" | jqWithin 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.
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:
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; doneWatch 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
Understand the execution model
How the Coordinator and Step Workers divide responsibility, tick by tick.
Learn the workflow vocabulary
Steps, edges, scatter/gather, sub-workflows, waits, and ContinueAsNew.
Route your first event
Walk the Validate → Enrich → Filter → Select → Assign pipeline.
See the architecture
The layered design, data flow, and how it all fits together.
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.