A standalone, language-neutral workflow orchestration server. Write durable workflows in any language. Built on the same engine as Durable Workflow.
Use this path when you want to validate the published image without cloning the
repository or starting MySQL/Redis. The image defaults to SQLite, database
queues, and file cache; mount /app/database so the bootstrap command and API
server share the same SQLite file.
export DW_SERVER_IMAGE=durableworkflow/server:0.2
export DW_AUTH_TOKEN=dev-token
docker volume create durable-workflow-sqlite
# Bootstrap schema + default namespace once.
docker run --rm \
-v durable-workflow-sqlite:/app/database \
-e DW_AUTH_DRIVER=token \
-e DW_AUTH_TOKEN="$DW_AUTH_TOKEN" \
"$DW_SERVER_IMAGE" server-bootstrap
# Start the API server.
docker run --rm --name durable-workflow-server \
-p 8080:8080 \
-v durable-workflow-sqlite:/app/database \
-e DW_AUTH_DRIVER=token \
-e DW_AUTH_TOKEN="$DW_AUTH_TOKEN" \
"$DW_SERVER_IMAGE"In another terminal:
curl http://localhost:8080/api/health
curl http://localhost:8080/api/ready
curl -H "Authorization: Bearer $DW_AUTH_TOKEN" \
http://localhost:8080/api/cluster/info
curl -X POST http://localhost:8080/api/worker/register \
-H "Authorization: Bearer $DW_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{"worker_id":"quickstart-worker","task_queue":"quickstart","runtime":"python"}'Use Redis or another shared cache backend for multi-node deployments. The file cache default is intentionally scoped to the one-container SQLite quickstart.
Use this path when you want a source-free multi-container stack backed by MySQL and Redis. The same Compose file supports local development and single-node production; the difference is the environment you provide and the operational care around persistence, backups, and upgrades.
Image selection:
DW_SERVER_TAG=0.2pullsdurableworkflow/server:0.2from Docker Hub.DW_SERVER_IMAGE=ghcr.io/durable-workflow/server:0.2pulls the same release line from GitHub Container Registry.DW_SERVER_IMAGE=durableworkflow/server@sha256:...pins an exact image digest for production change control.
This recipe is for one developer machine or internal non-production testing. It
uses the default MySQL/Redis volumes, exposes only the API port, and allows the
single DW_AUTH_TOKEN compatibility token for quick verification.
curl -fsSLO https://raw.githubusercontent.com/durable-workflow/server/main/docker-compose.published.yml
export DW_SERVER_TAG=0.2
export DW_AUTH_TOKEN=dev-token
docker compose -f docker-compose.published.yml up -d --waitVerify health, readiness, cluster discovery, and worker registration:
curl http://localhost:8080/api/health
curl http://localhost:8080/api/ready
curl -H "Authorization: Bearer $DW_AUTH_TOKEN" \
http://localhost:8080/api/cluster/info
curl -X POST http://localhost:8080/api/worker/register \
-H "Authorization: Bearer $DW_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{"worker_id":"compose-worker","task_queue":"compose","runtime":"python"}'This recipe is for a small self-hosted deployment on one Docker host. It keeps MySQL, Redis, and server storage in named volumes, exposes only the API port, and expects role-scoped credentials plus an exact image tag or digest.
Create a production env file outside source control:
DW_SERVER_IMAGE=durableworkflow/server:0.2
SERVER_PORT=8080
APP_ENV=production
APP_DEBUG=false
DB_DATABASE=durable_workflow
DB_USERNAME=workflow
DB_PASSWORD=replace-with-random-password
DB_ROOT_PASSWORD=replace-with-random-root-password
DW_AUTH_DRIVER=token
DW_AUTH_BACKWARD_COMPATIBLE=false
DW_WORKER_TOKEN=replace-with-worker-token
DW_OPERATOR_TOKEN=replace-with-operator-token
DW_ADMIN_TOKEN=replace-with-admin-tokenStart the stack and run the same readiness checks:
docker compose --env-file durable-workflow.prod.env \
-f docker-compose.published.yml up -d --wait
curl http://localhost:8080/api/health
curl http://localhost:8080/api/ready
curl -H "Authorization: Bearer $(grep '^DW_ADMIN_TOKEN=' durable-workflow.prod.env | cut -d= -f2-)" \
http://localhost:8080/api/cluster/infoRegister workers with DW_WORKER_TOKEN and send operator traffic with
DW_OPERATOR_TOKEN. Put TLS, request logging, and public routing in a reverse
proxy in front of the API container; do not expose the MySQL or Redis services.
Persistence and backups:
mysql_datais the durable workflow state. Back it up before every image upgrade and on a regular schedule.redis_datacontains queue/cache state. Preserve it for graceful restarts; MySQL remains the source of truth for workflow history.- Keep a copy of the exact env file and image reference with each backup so a restore uses the same auth, database, and image contract.
Backup and restore examples:
docker compose --env-file durable-workflow.prod.env \
-f docker-compose.published.yml exec -T mysql \
sh -lc 'mysqldump -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
> durable-workflow-$(date +%Y%m%d%H%M%S).sql
docker compose --env-file durable-workflow.prod.env \
-f docker-compose.published.yml exec -T mysql \
sh -lc 'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
< durable-workflow-backup.sqlUpgrade order:
- Back up MySQL and record the current image reference.
- Change only
DW_SERVER_IMAGEorDW_SERVER_TAGin the env file. - Run
docker compose --env-file durable-workflow.prod.env -f docker-compose.published.yml pull. - Run
docker compose --env-file durable-workflow.prod.env -f docker-compose.published.yml up -d --wait. - Confirm
/api/ready,/api/cluster/info, and worker registration before shifting external traffic.
The image generates an internal runtime key automatically. Set DW_SERVER_KEY
only if your deployment needs that key to remain stable across container
replacement.
The published Compose smoke workflow runs this file in both local and
production profiles for amd64 and arm64. The local profile validates the
single-token development recipe; the production profile validates role-scoped
worker/admin tokens with backward-compatible auth disabled.
Small clustered deployments without Kubernetes are validated as a narrow public support boundary, not as a general HA promise. The current supported shape uses external MySQL or PostgreSQL plus 2 or 3 API nodes behind a stateless load balancer, shared Redis, and independently scaled external workers. The first contract requires exactly one scheduler or maintenance runner. SQLite, Redis-less multi-node mode, duplicate schedulers, rolling upgrades, multi-region deployments, Helm charts, and provider-specific failover semantics are not part of that first contract.
The CI harness in docker-compose.small-cluster.yml runs the MySQL and
PostgreSQL variants with two API nodes, one bootstrap job, one scheduler, shared
Redis, load-balanced health/readiness/cluster-info checks, external worker
registration, and a workflow-task poll on one API node followed by completion
on the other. The Phase 0 rationale and harness details live in
docs/small-cluster-validation.md.
# Clone the repository
git clone https://github.com/durable-workflow/server.git
cd server
# Copy environment config
cp .env.example .env
# Start the server with all dependencies
docker compose up -d
# Verify
curl http://localhost:8080/api/health
curl http://localhost:8080/api/readyCompose runs a one-shot bootstrap service before the API and worker
containers start. That service calls the image's server-bootstrap command,
which runs migrations and seeds the default namespace.
The image build fetches the durable-workflow/workflow v2 package source by
default so docker compose up --build works from a clean checkout. Override
WORKFLOW_PACKAGE_SOURCE or WORKFLOW_PACKAGE_REF if you need a different
package remote or ref during image builds.
# Install the CLI
composer global require durable-workflow/cli
# Start a workflow
dw workflow start --type=my-workflow --input='{"name":"world"}'
# List workflows
dw workflow list
# Check server health
dw server healthThis walkthrough shows the full lifecycle using curl — start the server,
create a workflow, poll for tasks, and complete them. Any HTTP client in any
language follows the same steps.
Set role tokens for convenience (or set DW_AUTH_DRIVER=none in
.env to skip auth during development). If you only configure the legacy
DW_AUTH_TOKEN, use the same value for each variable below.
export ADMIN_TOKEN="your-admin-token"
export OPERATOR_TOKEN="your-operator-token"
export WORKER_TOKEN="your-worker-token"
export SERVER="http://localhost:8080"curl $SERVER/api/health{"status":"serving","timestamp":"2026-04-13T12:00:00Z"}The bootstrap seeds a default namespace. To create a dedicated one:
curl -X POST $SERVER/api/namespaces \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Durable-Workflow-Control-Plane-Version: 2" \
-d '{"name":"my-app","description":"My application namespace","retention_days":30}'curl -X POST $SERVER/api/workflows \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Control-Plane-Version: 2" \
-d '{
"workflow_id": "order-42",
"workflow_type": "orders.process",
"task_queue": "order-workers",
"input": ["order-42", {"rush": true}],
"execution_timeout_seconds": 3600,
"run_timeout_seconds": 600
}'{
"workflow_id": "order-42",
"run_id": "abc123",
"workflow_type": "orders.process",
"status": "pending",
"outcome": "started_new"
}Before polling, register the worker with the server:
curl -X POST $SERVER/api/worker/register \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"worker_id": "worker-1",
"task_queue": "order-workers",
"runtime": "python",
"supported_workflow_types": ["orders.process"],
"workflow_definition_fingerprints": {
"orders.process": "sha256:..."
}
}'When a worker re-registers the same active worker_id, any advertised
workflow type must keep the same workflow_definition_fingerprints value. A
changed fingerprint is rejected with workflow_definition_changed; restart
the process with a new worker id before serving a changed workflow class.
Workers that omit fingerprints during re-registration cannot clear previously
stored fingerprints for workflow types they still advertise; the server keeps
the stored value until a new worker id is used.
The server holds the connection open (long-poll) until a task is ready or the timeout expires:
curl -X POST $SERVER/api/worker/workflow-tasks/poll \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"worker_id": "worker-1",
"task_queue": "order-workers"
}'The response includes the task, its history events, and lease metadata:
{
"protocol_version": "1.0",
"task": {
"task_id": "task-xyz",
"workflow_id": "order-42",
"run_id": "abc123",
"workflow_type": "orders.process",
"workflow_task_attempt": 1,
"lease_owner": "worker-1",
"task_queue": "order-workers",
"history_events": [
{"sequence": 1, "event_type": "StartAccepted", "...": "..."},
{"sequence": 2, "event_type": "WorkflowStarted", "...": "..."}
]
}
}Replay history, execute logic, and return commands. To schedule an activity:
curl -X POST $SERVER/api/worker/workflow-tasks/task-xyz/complete \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"lease_owner": "worker-1",
"workflow_task_attempt": 1,
"commands": [
{
"type": "schedule_activity",
"activity_type": "orders.send-confirmation",
"task_queue": "order-workers",
"input": ["order-42"]
}
]
}'To complete the workflow (terminal command):
curl -X POST $SERVER/api/worker/workflow-tasks/task-xyz/complete \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"lease_owner": "worker-1",
"workflow_task_attempt": 1,
"commands": [
{
"type": "complete_workflow",
"result": {"status": "shipped", "tracking": "TRK-123"}
}
]
}'If the workflow scheduled activities, poll for them on the same (or different) queue:
# Poll
curl -X POST $SERVER/api/worker/activity-tasks/poll \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{"worker_id": "worker-1", "task_queue": "order-workers"}'
# Complete (use task_id and activity_attempt_id from the poll response)
curl -X POST $SERVER/api/worker/activity-tasks/TASK_ID/complete \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"activity_attempt_id": "ATTEMPT_ID",
"lease_owner": "worker-1",
"result": "confirmation-sent"
}'curl $SERVER/api/workflows/order-42 \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Control-Plane-Version: 2"curl "$SERVER/api/workflows/order-42/runs/abc123/history" \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Control-Plane-Version: 2"| Command | Terminal | Description |
|---|---|---|
complete_workflow |
Yes | Complete workflow with a result |
fail_workflow |
Yes | Fail workflow with an error |
continue_as_new |
Yes | Continue as a new run |
schedule_activity |
No | Schedule an activity for execution |
start_timer |
No | Start a durable timer |
start_child_workflow |
No | Start a child workflow |
record_side_effect |
No | Record a non-deterministic value |
record_version_marker |
No | Record a version marker |
upsert_search_attributes |
No | Update search attributes |
GET /api/health— Health checkGET /api/ready— Readiness check for migrations, default namespace, cache, and auth configGET /api/cluster/info— Server capabilities and versionGET /api/system/metrics— Server metrics including bounded stuck workflow-task diagnosticsGET /api/system/repair— Task repair diagnosticsPOST /api/system/repair/pass— Run task repair sweepGET /api/system/activity-timeouts— Expired activity execution diagnosticsPOST /api/system/activity-timeouts/pass— Enforce activity timeouts
GET /api/namespaces— List namespacesPOST /api/namespaces— Create namespaceGET /api/namespaces/{name}— Get namespacePUT /api/namespaces/{name}— Update namespace
GET /api/workflows— List workflows (with filters)POST /api/workflows— Start a workflowGET /api/workflows/{id}— Describe a workflowGET /api/workflows/{id}/runs— List runs (continue-as-new chain)GET /api/workflows/{id}/runs/{runId}— Describe a specific runGET /api/workflows/{id}/debug— Bounded support diagnostic for the current runGET /api/workflows/{id}/runs/{runId}/debug— Bounded support diagnostic for a specific runPOST /api/workflows/{id}/signal/{name}— Send a signalPOST /api/workflows/{id}/query/{name}— Execute a queryPOST /api/workflows/{id}/update/{name}— Execute an updatePOST /api/workflows/{id}/cancel— Request cancellationPOST /api/workflows/{id}/terminate— Terminate immediately
Workflow debug responses are capped support snapshots, not full run exports:
the server fetches at most 25 pending workflow tasks, 25 pending activities
with only each activity's current/latest attempt, and 10 recent failures. The
last history event includes only sequence, type, timestamp, and bounded payload
metadata by default; add include_last_event_payload=true to include at most a
4 KiB JSON preview. Use the history endpoints when a full replay/debug archive
is needed.
GET /api/workflows/{id}/runs/{runId}/history— Get event historyGET /api/workflows/{id}/runs/{runId}/history/export— Export replay bundle
Every non-health, non-discovery control-plane endpoint must send
X-Durable-Workflow-Control-Plane-Version: 2 on the request. That
covers namespace, schedule, search-attribute, task-queue, worker-management,
system, workflow, and history endpoints. Requests without that header or
with legacy wait_policy fields are rejected. Mutating requests with bodies
must use Content-Type: application/json or another application/*+json media
type; XML, form, and other body formats return a versioned 415 response before
controller work. Workflow and history responses always return the same header.
The v2 canonical workflow command fields are
workflow_id, command_status, outcome, plus signal_name, query_name,
or update_name where applicable and, for updates, wait_for,
wait_timed_out, and wait_timeout_seconds.
Validation failures return HTTP 422 with reason: validation_failed plus both
errors and validation_errors; workflow operation routes also project that
reason and validation detail into the nested control_plane metadata. Current
run-targeted command routes project the URL run_id in the response and
control_plane.run_id so clients can distinguish instance-level commands from
explicit selected-run commands.
Only GET /api/health, GET /api/ready, and GET /api/cluster/info are
exempt. They are intentionally version-free so probes can check liveness and
readiness, and clients can discover the supported control-plane version before
adopting it.
Workflow control-plane responses, including run-history listing responses, also
publish a nested, independently versioned control_plane.contract boundary
with:
schema: durable-workflow.v2.control-plane-response.contractversion: 1legacy_field_policy: reject_non_canonicallegacy_fields,required_fields, andsuccess_fields
Clients can validate that nested contract separately from the outer
control_plane envelope.
History export responses are the exception inside the workflow route group:
GET /api/workflows/{id}/runs/{runId}/history/export returns the replay bundle
as-is so its integrity checksum and optional signature cover the exact artifact
the client receives.
The server also publishes the current request contract in
GET /api/cluster/info under control_plane.request_contract with:
schema: durable-workflow.v2.control-plane-request.contractversion: 1operations
Treat that versioned manifest as the source of truth for canonical request
values, rejected aliases, and removed fields such as start
duplicate_policy and update wait_for. Clients should reject missing or
unknown request-contract schema or version instead of silently guessing.
GET /api/cluster/info also includes client_compatibility, whose
authority is protocol_manifests. The top-level server version is build
identity only; CLI and SDK compatibility must be decided from
control_plane.version, control_plane.request_contract, and, for workers,
worker_protocol.version. Unknown, missing, or undiscoverable protocol
manifests should fail closed.
POST /api/worker/register— Register a workerPOST /api/worker/heartbeat— Worker heartbeatPOST /api/worker/workflow-tasks/poll— Long-poll for workflow tasksPOST /api/worker/workflow-tasks/{id}/heartbeat— Workflow task heartbeatPOST /api/worker/workflow-tasks/{id}/complete— Complete workflow taskPOST /api/worker/workflow-tasks/{id}/fail— Fail workflow taskPOST /api/worker/activity-tasks/poll— Long-poll for activity tasksPOST /api/worker/activity-tasks/{id}/complete— Complete activity taskPOST /api/worker/activity-tasks/{id}/fail— Fail activity taskPOST /api/worker/activity-tasks/{id}/heartbeat— Activity heartbeat
Worker-plane requests must send X-Durable-Workflow-Protocol-Version: 1.0, and
worker-plane responses always echo the same header plus protocol_version: "1.0".
Worker requests with bodies follow the same JSON media-type requirement as the
control plane and return a worker-protocol 415 response for XML, form, or other
non-JSON body formats.
Worker registration, poll, heartbeat, complete, and fail responses all include
server_capabilities.supported_workflow_task_commands so SDK workers can
negotiate whether the server only supports terminal workflow-task commands or
the expanded non-terminal command set. The same server_capabilities object
also advertises command-option support for activity retry policies, activity
timeouts, child workflow retry policies, child workflow timeouts, parent-close
policy, and non-retryable failures. SDK workers can therefore negotiate worker
behavior from either GET /api/cluster/info or any worker-plane response.
Long-poll wake-ups use short-lived cache-backed signal keys plus periodic reprobes. Multi-node deployments therefore need a shared cache backend for prompt wake behavior; without one, correctness still comes from the periodic database recheck, but wake latency will regress toward the forced recheck interval.
Server-owned cache keys and metric label sets are governed by the bounded-growth
policy in config/dw-bounded-growth.php; the human-readable inventory lives in
docs/bounded-growth.md.
Within worker protocol version 1.0, worker_protocol.version,
server_capabilities.long_poll_timeout, and
server_capabilities.supported_workflow_task_commands are stable contract
fields. The command-option booleans under server_capabilities are additive
worker capability fields. Adding new workflow-task commands or optional
capability booleans is additive; removing or renaming a command or capability
requires a protocol version bump.
Workflow task polling returns a leased task plus workflow_task_attempt. Clients
must echo both workflow_task_attempt and lease_owner on workflow-task
heartbeat, complete, and fail calls. Workflow-task completion supports
non-terminal commands such as schedule_activity, start_timer,
start_child_workflow, complete_update, and fail_update, plus terminal
complete_workflow, fail_workflow, and continue_as_new commands. Workers
use complete_update with update_id and an optional codec-tagged result
after applying an accepted update, or fail_update with update_id,
message, and optional exception metadata when the update handler fails. Poll
responses also expose stable resume
context fields from the durable task payload: workflow_wait_kind,
open_wait_id, resume_source_kind, resume_source_id,
workflow_update_id, workflow_signal_id, workflow_command_id,
signal_name, signal_wait_id, activity_execution_id,
activity_attempt_id, activity_type,
child_call_id, child_workflow_run_id, workflow_sequence,
workflow_event_type, timer_id, condition_wait_id, condition_key, and
condition_definition_fingerprint. Fields that do not apply to the leased task
are null; pure timer resumes set
workflow_wait_kind: "timer", open_wait_id: "timer:{timer_id}", and
timer_id so SDK workers can apply timer-fired history directly. Update-backed
tasks set
workflow_wait_kind: "update" and workflow_update_id so SDK workers can tie
the task to the accepted update they are applying. Signal-backed tasks set
workflow_wait_kind: "signal", workflow_signal_id, signal_name, and
signal_wait_id so SDK workers can tie the task to the accepted signal or
timer-backed signal wait they are applying, while activity-backed resume tasks
set workflow_wait_kind: "activity" and activity_execution_id so workers can
apply completed or failed activity history without scanning the full event
stream. Timer-backed condition resumes set workflow_wait_kind: "condition",
condition_wait_id, condition_key, and
condition_definition_fingerprint when the original wait recorded them. If a
cancel or terminate command closes the run while a workflow task
is leased, the next workflow-task
history, heartbeat, complete, or fail response returns the worker
envelope with reason: "run_closed", can_continue: false,
cancel_requested: true, and a concrete stop_reason such as run_cancelled
or run_terminated. The response also includes run_closed_reason and
run_closed_at from the durable run so external workers can log the exact
closure state that stopped their leased task.
Activity task polling returns a leased attempt identity. Clients must echo both
activity_attempt_id and lease_owner on activity complete, fail, and
heartbeat calls. When the activity execution has timeout deadlines configured,
the poll response includes a deadlines object with ISO-8601 timestamps for
schedule_to_start, start_to_close, schedule_to_close, and/or heartbeat.
Workers should use these deadlines to self-cancel before the server enforces the
timeout. The server runs activity:timeout-enforce periodically to expire
activities that exceed their deadlines. Heartbeats accept message, current,
total, unit, and details fields; the server normalizes them to the package
heartbeat-progress contract before recording the heartbeat.
When a run-level cancel or terminate command stops a leased activity task,
heartbeat, complete, and fail responses include run_closed_reason and
run_closed_at alongside cancel_requested: true.
GET /api/schedules— List schedulesPOST /api/schedules— Create scheduleGET /api/schedules/{id}— Describe schedulePUT /api/schedules/{id}— Update scheduleDELETE /api/schedules/{id}— Delete schedulePOST /api/schedules/{id}/pause— Pause schedulePOST /api/schedules/{id}/resume— Resume schedulePOST /api/schedules/{id}/trigger— Trigger immediatelyPOST /api/schedules/{id}/backfill— Backfill missed runs
GET /api/task-queues— List task queuesGET /api/task-queues/{name}— Task queue details and pollers
Task queue responses include an admission object so operators can separate
worker-local capacity from server-side queue and query-task admission limits. Workflow
and activity entries report active worker count, configured slots from worker
registrations, leased and ready counts, available slots, optional server-side
queue and namespace active lease caps, optional queue and namespace per-minute
dispatch caps, and a status such as
accepting, throttled, saturated, no_slots, or no_active_workers. Set
DW_WORKFLOW_TASK_MAX_ACTIVE_LEASES_PER_QUEUE and
DW_ACTIVITY_TASK_MAX_ACTIVE_LEASES_PER_QUEUE to cap active leases per
namespace/task queue. Set DW_WORKFLOW_TASK_MAX_ACTIVE_LEASES_PER_NAMESPACE
and DW_ACTIVITY_TASK_MAX_ACTIVE_LEASES_PER_NAMESPACE to cap active leases
across all task queues in a namespace. Set DW_WORKFLOW_TASK_MAX_DISPATCHES_PER_MINUTE and
DW_ACTIVITY_TASK_MAX_DISPATCHES_PER_MINUTE to smooth downstream dispatch per
namespace/task queue. Set DW_WORKFLOW_TASK_MAX_DISPATCHES_PER_MINUTE_PER_NAMESPACE
and DW_ACTIVITY_TASK_MAX_DISPATCHES_PER_MINUTE_PER_NAMESPACE to smooth
tenant-wide dispatch across all queues in a namespace, or use
DW_TASK_QUEUE_ADMISSION_OVERRIDES for exact queue and namespace overrides
keyed by namespace:task_queue, namespace:*, task_queue, or *. Override
entries may set workflow_tasks.max_active_leases,
workflow_tasks.max_active_leases_per_namespace,
workflow_tasks.max_dispatches_per_minute, activity_tasks.max_active_leases,
workflow_tasks.max_dispatches_per_minute_per_namespace,
activity_tasks.max_active_leases_per_namespace,
activity_tasks.max_dispatches_per_minute, or
activity_tasks.max_dispatches_per_minute_per_namespace. Query-task
entries report server.query_tasks.max_pending_per_queue, approximate pending
count, remaining capacity, cache-lock support, and whether the queue is
accepting, full, or unavailable.
GET /api/search-attributes— List search attributesPOST /api/search-attributes— Register custom attributeDELETE /api/search-attributes/{name}— Remove custom attribute
Set the X-Namespace header to target a specific namespace (defaults to default).
Requests that name a namespace which is not registered receive a 404 with
reason: "namespace_not_found"; register the namespace via
POST /api/namespaces before directing traffic to it. The namespace
administration endpoints (/api/namespaces/**), cluster discovery
(/api/cluster/info), and the unauthenticated /api/health and /api/ready
probes are exempt from this check.
For production, prefer role-scoped tokens:
DW_AUTH_DRIVER=token
DW_WORKER_TOKEN=worker-secret
DW_OPERATOR_TOKEN=operator-secret
DW_ADMIN_TOKEN=admin-secretworker tokens can call /api/worker/* and /api/cluster/info. operator
tokens can call workflow, history, schedule, search-attribute, task-queue,
worker-read, and namespace-read endpoints. admin tokens can call admin
operations such as /api/system/*, namespace creation/update, and worker
deletion, and can also use operator endpoints.
curl -H "Authorization: Bearer operator-secret" \
-H "X-Durable-Workflow-Control-Plane-Version: 2" \
http://localhost:8080/api/workflowsExisting deployments can keep DW_AUTH_TOKEN. When no role tokens
are configured, that legacy token keeps full API access. Once any role token is
configured, the legacy token is treated as an admin token and no longer grants
worker-plane access. Set DW_AUTH_BACKWARD_COMPATIBLE=false to
require role-scoped credentials only.
Signature auth supports the same role split with role-scoped HMAC keys:
DW_AUTH_DRIVER=signature
DW_WORKER_SIGNATURE_KEY=worker-hmac-key
DW_OPERATOR_SIGNATURE_KEY=operator-hmac-key
DW_ADMIN_SIGNATURE_KEY=admin-hmac-key# HMAC-SHA256 of the request body
curl -H "X-Signature: COMPUTED_SIGNATURE" \
-H "X-Durable-Workflow-Control-Plane-Version: 2" \
http://localhost:8080/api/workflowsThe legacy DW_SIGNATURE_KEY follows the same compatibility rule
as the legacy bearer token.
Set DW_AUTH_DRIVER=none to disable authentication (development only).
Set DW_AUTH_PROVIDER to the fully-qualified class name of a Laravel
container-resolvable implementation of App\Contracts\AuthProvider to replace
the built-in token/signature provider without editing server middleware. The
provider returns an App\Auth\Principal from authenticate(Request $request)
and receives each route authorization decision as
authorize(Principal $principal, string $action, array $resource): bool.
The route resource includes allowed_roles, HTTP method/path, route name/URI,
normalized requested_namespace, default_namespace, route parameters,
operation_family, operation_name, and stable identifier fields such as
workflow_id, run_id, signal_name, query_name, update_name, task_id,
query_task_id, task_queue, worker_id, schedule_id, and
search_attribute_name when those identifiers are present on the route or in
the worker request body. This resource is built before namespace existence is
validated, so tenant-aware providers can deny access by namespace or workflow
resource without reparsing raw paths and without revealing whether a namespace
exists. The authenticated principal is also recorded in workflow command
attribution so signal/update/query history can show the subject, roles, tenant,
and non-secret claims supplied by the provider. When DW_AUTH_PROVIDER is set,
/api/ready verifies that the class resolves and implements AuthProvider;
built-in token or signature credentials are not required for readiness.
docker build -t durable-workflow-server .
export DW_AUTH_TOKEN=dev-token
docker volume create durable-workflow-sqlite
# Bootstrap schema + default namespace once
docker run --rm \
-v durable-workflow-sqlite:/app/database \
-e DW_AUTH_DRIVER=token \
-e DW_AUTH_TOKEN="$DW_AUTH_TOKEN" \
durable-workflow-server server-bootstrap
# Start the API server
docker run --rm -p 8080:8080 \
-v durable-workflow-sqlite:/app/database \
-e DW_AUTH_DRIVER=token \
-e DW_AUTH_TOKEN="$DW_AUTH_TOKEN" \
durable-workflow-serverThe Dockerfile clones the durable-workflow/workflow v2 branch into the
build and satisfies the app's Composer path repository from that source. Use
--build-arg WORKFLOW_PACKAGE_SOURCE=... and
--build-arg WORKFLOW_PACKAGE_REF=... to point the image build at another
remote or ref when needed.
The production image defaults to DB_CONNECTION=sqlite,
DB_DATABASE=/app/database/database.sqlite, QUEUE_CONNECTION=database, and
CACHE_STORE=file so the plain Docker quickstart works without external
services. The entrypoint creates the SQLite file when a fresh volume is mounted.
Override those framework variables for MySQL/PostgreSQL/Redis deployments.
Across Compose, plain Docker, and Kubernetes, the supported bootstrap contract
is the same: run the image's server-bootstrap command once before starting the
server and worker processes. /api/health is a liveness check; /api/ready
is the readiness check to gate workers and load balancers.
The Release workflow publishes multi-arch images to
Docker Hub (durableworkflow/server) and GitHub Container Registry
(ghcr.io/durable-workflow/server) when a server semver tag is pushed. The
workflow builds the server image with the latest durable-workflow/workflow
prerelease tag that matches 2.0.0-alpha.* or 2.0.0-beta.*, falling back to
the v2 branch only when no prerelease tags exist.
When the server image needs a workflow package fix that has only landed on the
workflow v2 branch, tag workflow first, then tag server:
# In the workflow repo, publish the package ref the server image must consume.
git tag 2.0.0-alpha.3 origin/v2
git push origin refs/tags/2.0.0-alpha.3
# In the server repo, publish the Docker image tags.
git tag 0.2.0 origin/main
git push origin refs/tags/0.2.0The server tag push publishes the exact version plus the semver aliases
generated by the release workflow, including latest, to both registries. After
the workflow finishes, verify the image provenance and runtime config before
announcing the release:
docker pull durableworkflow/server:0.2.0
docker run --rm --entrypoint sh durableworkflow/server:0.2.0 -lc \
'cat /app/.package-provenance && grep -n "serializer" /app/vendor/durable-workflow/workflow/src/config/workflows.php'
docker pull ghcr.io/durable-workflow/server:0.2.0
docker run --rm --entrypoint sh ghcr.io/durable-workflow/server:0.2.0 -lc \
'cat /app/.package-provenance && grep -n "serializer" /app/vendor/durable-workflow/workflow/src/config/workflows.php'The raw manifests intentionally stay Kubernetes-native instead of shipping a Helm chart. Use Kustomize overlays or direct patches for environment-specific names, images, registry secrets, and scaling policy; revisit Helm only when an operator needs chart versioning and a chart/image compatibility matrix.
The public manifests default to the pinned Docker Hub image
durableworkflow/server:0.2. For production, patch every workload to the exact
Docker Hub or GHCR tag or digest you intend to run before applying it. See
k8s/README.md for the raw-manifest support boundary and
image-pinning contract.
The supported apply order is configuration first, migration second, and long-running workloads last. The helper script enforces that order, deletes any previous completed migration job so a new deploy runs bootstrap again, waits for completion, and only then applies the server, worker, scheduler, and disruption budget manifests:
scripts/deploy-k8s.shBefore running it, create the externally managed credentials referenced by the
pod templates. Keep DB/Redis credentials out of k8s/secret.yaml; manage them
with your secret manager, External Secrets operator, or kubectl:
# Required by every pod template.
kubectl apply -f k8s/namespace.yaml
kubectl create secret generic durable-workflow-database \
--namespace durable-workflow \
--from-literal=DB_USERNAME=workflow \
--from-literal=DB_PASSWORD='CHANGE_ME'
# Optional; only create this when Redis requires auth.
kubectl create secret generic durable-workflow-redis \
--namespace durable-workflow \
--from-literal=REDIS_USERNAME='<username>' \
--from-literal=REDIS_PASSWORD='<password>'
# App config and app-level secrets only.
kubectl apply -f k8s/secret.yaml
# Manual equivalent of scripts/deploy-k8s.sh.
kubectl apply -f k8s/migration-job.yaml
kubectl -n durable-workflow wait --for=condition=complete --timeout=300s job/durable-workflow-migrate
kubectl apply -f k8s/server-pdb.yaml
kubectl apply -f k8s/server-deployment.yaml
kubectl apply -f k8s/worker-deployment.yaml
kubectl apply -f k8s/scheduler-cronjob.yamlThe Deployment manifests omit spec.replicas so HorizontalPodAutoscalers and
operator overlays own replica count. For static installs, set replicas in your
overlay or with kubectl scale.
All operator-facing configuration is via DW_* environment variables.
config/dw-contract.php is the authoritative machine-checkable contract;
CI (tests/Unit/EnvContractTest.php) diffs it against .env.example,
docker-compose.yml, and k8s/secret.yaml so the three surfaces cannot
drift. The Docker entrypoint runs php artisan env:audit at boot and
logs a warning for any unknown DW_* variable and any legacy
WORKFLOW_* / ACTIVITY_* name that still resolves.
Rules — every DW_* name is stable across minor versions. Additions are
fine; renames require a major bump with the old name alias-honored for
one major. Set DW_ENV_AUDIT_STRICT=1 to fail container boot when the
audit finds drift.
The full table below is generated from config/dw-contract.php and lists
every operator-facing variable the server honors.
DW_* name |
Default | Description |
|---|---|---|
DW_MODE |
service |
Server mode: "service" (external workers poll) or "embedded" (local queue). |
DW_SERVER_ID |
gethostname() |
Unique identifier for this server instance. |
DW_SERVER_KEY |
generated at container boot | Optional server-internal runtime key. |
DW_DEFAULT_NAMESPACE |
default |
Namespace used when a request omits the namespace header. |
DW_TASK_DISPATCH_MODE |
(unset) | Override for workflows.v2.task_dispatch_mode. Set to queue to dispatch locally in service mode. |
DW_AUTH_PROVIDER |
(unset) | Optional FQCN implementing App\Contracts\AuthProvider; unset uses the built-in driver. |
DW_AUTH_DRIVER |
token |
none, token, or signature. |
DW_AUTH_TOKEN |
(unset) | Single shared bearer token (backward-compat credential). |
DW_SIGNATURE_KEY |
(unset) | HMAC key used when DW_AUTH_DRIVER=signature and no role-scoped key is configured. |
DW_WORKER_TOKEN |
(unset) | Bearer token for the worker role. |
DW_OPERATOR_TOKEN |
(unset) | Bearer token for the operator role. |
DW_ADMIN_TOKEN |
(unset) | Bearer token for the admin role. |
DW_WORKER_SIGNATURE_KEY |
(unset) | Role-scoped HMAC key for workers. |
DW_OPERATOR_SIGNATURE_KEY |
(unset) | Role-scoped HMAC key for operators. |
DW_ADMIN_SIGNATURE_KEY |
(unset) | Role-scoped HMAC key for admins. |
DW_AUTH_BACKWARD_COMPATIBLE |
true |
Honor DW_AUTH_TOKEN / DW_SIGNATURE_KEY as a fallback when role credentials are missing. |
DW_TRUST_FORWARDED_ATTRIBUTION_HEADERS |
false |
Accept forwarded caller/auth headers from a trusted gateway. |
DW_CALLER_TYPE_HEADER |
X-Workflow-Caller-Type |
Request header carrying the forwarded caller type. |
DW_CALLER_LABEL_HEADER |
X-Workflow-Caller-Label |
Request header carrying the forwarded caller label. |
DW_AUTH_STATUS_HEADER |
X-Workflow-Auth-Status |
Request header carrying the forwarded auth status. |
DW_AUTH_METHOD_HEADER |
X-Workflow-Auth-Method |
Request header carrying the forwarded auth method. |
DW_WORKER_POLL_TIMEOUT |
30 |
Seconds the server holds a poll open. |
DW_WORKER_POLL_INTERVAL_MS |
1000 |
Internal scan interval during an open poll. |
DW_WORKER_POLL_SIGNAL_CHECK_INTERVAL_MS |
100 |
Wake-signal check interval during an open poll. |
DW_POLLING_CACHE_PATH |
storage/.../server-polling/<APP_ENV> |
Directory for worker-poll coordination state. |
DW_WAKE_SIGNAL_TTL_SECONDS |
max(DW_WORKER_POLL_TIMEOUT + 5, 60) |
TTL for per-queue wake signals. |
DW_MAX_TASKS_PER_POLL |
1 |
Maximum tasks returned per poll. |
DW_WORKFLOW_TASK_MAX_ACTIVE_LEASES_PER_QUEUE |
(unset) | Optional server-side cap for active workflow-task leases per namespace/task queue. |
DW_WORKFLOW_TASK_MAX_ACTIVE_LEASES_PER_NAMESPACE |
(unset) | Optional server-side cap for active workflow-task leases across all task queues in a namespace. |
DW_WORKFLOW_TASK_MAX_DISPATCHES_PER_MINUTE |
(unset) | Optional server-side cap for workflow-task dispatches per minute per namespace/task queue. |
DW_WORKFLOW_TASK_MAX_DISPATCHES_PER_MINUTE_PER_NAMESPACE |
(unset) | Optional server-side cap for workflow-task dispatches per minute across all task queues in a namespace. |
DW_ACTIVITY_TASK_MAX_ACTIVE_LEASES_PER_QUEUE |
(unset) | Optional server-side cap for active activity-task leases per namespace/task queue. |
DW_ACTIVITY_TASK_MAX_ACTIVE_LEASES_PER_NAMESPACE |
(unset) | Optional server-side cap for active activity-task leases across all task queues in a namespace. |
DW_ACTIVITY_TASK_MAX_DISPATCHES_PER_MINUTE |
(unset) | Optional server-side cap for activity-task dispatches per minute per namespace/task queue. |
DW_ACTIVITY_TASK_MAX_DISPATCHES_PER_MINUTE_PER_NAMESPACE |
(unset) | Optional server-side cap for activity-task dispatches per minute across all task queues in a namespace. |
DW_TASK_QUEUE_ADMISSION_OVERRIDES |
{} |
JSON overrides keyed by namespace:task_queue, namespace:*, task_queue, or * for workflow/activity active lease and dispatch-per-minute caps. |
DW_EXPIRED_WORKFLOW_TASK_RECOVERY_SCAN_LIMIT |
5 |
Max expired workflow tasks recovered per pass. |
DW_EXPIRED_WORKFLOW_TASK_RECOVERY_TTL_SECONDS |
5 |
Min seconds between expired-task recovery passes. |
DW_WORKER_PROTOCOL_VERSION |
WorkerProtocolVersion::VERSION |
Override for the advertised worker protocol version. |
DW_HISTORY_PAGE_SIZE_DEFAULT |
DEFAULT_HISTORY_PAGE_SIZE |
Default page size for worker history reads. |
DW_HISTORY_PAGE_SIZE_MAX |
MAX_HISTORY_PAGE_SIZE |
Maximum page size honored for worker history reads. |
DW_QUERY_TASK_TIMEOUT |
DW_WORKER_POLL_TIMEOUT |
Seconds the control plane waits for a worker query response. |
DW_QUERY_TASK_LEASE_TIMEOUT |
DW_WORKFLOW_TASK_TIMEOUT |
Lease timeout for ephemeral query tasks. |
DW_QUERY_TASK_TTL_SECONDS |
180 |
Retention for query-task result rows. |
DW_QUERY_TASK_MAX_PENDING_PER_QUEUE |
1024 |
Max pending cache-backed query tasks per namespace/task queue before new queries are rejected. |
DW_WORKFLOW_TASK_TIMEOUT |
60 |
Default workflow-task lease timeout (seconds). |
DW_ACTIVITY_TASK_TIMEOUT |
300 |
Default activity-task lease timeout (seconds). |
DW_WORKER_STALE_AFTER_SECONDS |
max(DW_WORKER_POLL_TIMEOUT * 2, 60) |
Seconds before a worker heartbeat is considered stale. |
DW_MAX_HISTORY_EVENTS |
50000 |
Max history events per run before continue-as-new is enforced. |
DW_HISTORY_RETENTION_DAYS |
30 |
Default retention for closed-run history (namespaces can override). |
DW_MAX_PAYLOAD_BYTES |
2097152 |
Max serialized bytes for a single payload. |
DW_MAX_MEMO_BYTES |
262144 |
Max serialized bytes for a workflow memo. |
DW_MAX_SEARCH_ATTRIBUTES |
100 |
Max search attributes per workflow. |
DW_MAX_PENDING_ACTIVITIES |
2000 |
Max pending activities per run. |
DW_MAX_PENDING_CHILDREN |
2000 |
Max pending child workflows per run. |
DW_COMPRESSION_ENABLED |
true |
Enable gzip/deflate on JSON responses over the size threshold. |
DW_EXPOSE_PACKAGE_PROVENANCE |
false |
Include package_provenance in /api/cluster/info (admin-only). |
DW_PACKAGE_PROVENANCE_PATH |
<base_path>/.package-provenance |
Path to the package provenance file written at Docker build time. |
DW_ENV_AUDIT_STRICT |
0 |
When 1, the entrypoint fails container boot on unknown/legacy DW vars. |
DW_BOOTSTRAP_RETRIES |
30 |
Bootstrap attempts before the entrypoint gives up. |
DW_BOOTSTRAP_DELAY_SECONDS |
2 |
Seconds between bootstrap attempts. |
The bundled durable-workflow/workflow package reads the same
DW_V2_* prefix for operator controls; every entry below is resolved
inside the package's config/workflows.php via
Workflow\Support\Env::dw and falls back to its legacy
WORKFLOW_V2_* counterpart the same way the server's own vars do.
DW_* name |
Default | Description |
|---|---|---|
DW_V2_NAMESPACE |
(unset) | Scope workflow instances to a namespace. Unset means the default, visible-to-every-consumer namespace. |
DW_V2_CURRENT_COMPATIBILITY |
(unset) | Worker-compatibility marker this worker advertises (e.g. build-2026-04-17). |
DW_V2_SUPPORTED_COMPATIBILITIES |
(unset) | Comma-separated marker list the worker accepts, or * for any. |
DW_V2_COMPATIBILITY_NAMESPACE |
(unset) | Compatibility namespace for independent fleets sharing one database. |
DW_V2_COMPATIBILITY_HEARTBEAT_TTL |
30 |
Seconds a worker-compatibility heartbeat remains valid. |
DW_V2_PIN_TO_RECORDED_FINGERPRINT |
true |
Resolve in-flight runs from the fingerprint recorded at WorkflowStarted. |
DW_V2_CONTINUE_AS_NEW_EVENT_THRESHOLD |
10000 |
History event count at which the package signals continue-as-new. |
DW_V2_CONTINUE_AS_NEW_SIZE_BYTES_THRESHOLD |
5242880 |
Serialized-history byte count at which the package signals continue-as-new. |
DW_V2_HISTORY_EXPORT_SIGNING_KEY |
(unset) | Optional HMAC key authenticating history export archives. |
DW_V2_HISTORY_EXPORT_SIGNING_KEY_ID |
(unset) | Optional key identifier recorded alongside signed exports. |
DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS |
10 |
Seconds the server waits for an update to reach a terminal stage. |
DW_V2_UPDATE_WAIT_POLL_INTERVAL_MS |
50 |
Milliseconds between update-stage polls. |
DW_V2_GUARDRAILS_BOOT |
warn |
Boot-time structural guardrail mode: warn, fail, or silent. |
DW_V2_LIMIT_PENDING_ACTIVITIES |
2000 |
Package-level pending-activity ceiling per run. |
DW_V2_LIMIT_PENDING_CHILDREN |
1000 |
Package-level pending-child ceiling per run. |
DW_V2_LIMIT_PENDING_TIMERS |
2000 |
Package-level pending-timer ceiling per run. |
DW_V2_LIMIT_PENDING_SIGNALS |
5000 |
Package-level pending-signal ceiling per run. |
DW_V2_LIMIT_PENDING_UPDATES |
500 |
Package-level pending-update ceiling per run. |
DW_V2_LIMIT_COMMAND_BATCH_SIZE |
1000 |
Maximum commands accepted per workflow-task completion. |
DW_V2_LIMIT_PAYLOAD_SIZE_BYTES |
2097152 |
Package-level single-payload byte ceiling. |
DW_V2_LIMIT_MEMO_SIZE_BYTES |
262144 |
Package-level memo byte ceiling. |
DW_V2_LIMIT_SEARCH_ATTRIBUTE_SIZE_BYTES |
40960 |
Package-level search-attribute byte ceiling. |
DW_V2_LIMIT_HISTORY_TRANSACTION_SIZE |
5000 |
Package-level history-transaction event ceiling. |
DW_V2_LIMIT_WARNING_THRESHOLD_PERCENT |
80 |
Percent of a structural limit at which the package warns. |
DW_V2_TASK_DISPATCH_MODE |
queue |
Package-level workflow-task dispatch mode. Usually overridden by the server via DW_TASK_DISPATCH_MODE. |
DW_V2_TASK_REPAIR_REDISPATCH_AFTER_SECONDS |
3 |
Seconds before an orphaned workflow task is redispatched. |
DW_V2_TASK_REPAIR_LOOP_THROTTLE_SECONDS |
5 |
Minimum seconds between successive task-repair passes. |
DW_V2_TASK_REPAIR_SCAN_LIMIT |
25 |
Maximum tasks considered per task-repair pass. |
DW_V2_TASK_REPAIR_FAILURE_BACKOFF_MAX_SECONDS |
60 |
Ceiling on task-repair failure backoff in seconds. |
DW_V2_MULTI_NODE |
false |
Declare multi-node deployment so cache backends are validated for cross-node coordination. |
DW_V2_VALIDATE_CACHE_BACKEND |
true |
Validate the long-poll cache backend at boot. |
DW_V2_CACHE_VALIDATION_MODE |
warn |
Cache-backend validation failure handling: fail, warn, or silent. |
DW_SERIALIZER |
avro |
Payload codec diagnostic input. Legacy values are surfaced by workflow:v2:doctor; new-run v2 payloads always resolve to Avro. |
Legacy WORKFLOW_* / WORKFLOW_V2_* / ACTIVITY_* names remain
honored as fallbacks during the deprecation window so existing
deployments keep working — env:audit logs a rename hint at boot for
each one it sees.
The image's default CMD runs php artisan serve --no-reload with
PHP_CLI_SERVER_WORKERS=4. The --no-reload flag is required for
Laravel's built-in server to honour the worker count — without it the
server logs Unable to respect the PHP_CLI_SERVER_WORKERS environment variable without the --no-reload flag and falls back to a single
thread, which will block every other request while one worker holds a
long-poll connection open.
Raise the worker count for polyglot or multi-worker deployments:
docker run --rm -p 8080:8080 -e PHP_CLI_SERVER_WORKERS=16 \
--env-file .env durable-workflow-serverFor production workloads the php artisan serve built-in server is a
reasonable default but not the ceiling — FrankenPHP, RoadRunner, or an
nginx/php-fpm pair are all valid replacements and only require
overriding the container's CMD.
Workers in any language connect to the server via HTTP. The protocol is simple:
- Register the worker with supported types
- Poll for tasks (long-poll, server holds connection)
- Execute the task locally
- Complete or fail the task back to the server
- Heartbeat for long-running activities
use DurableWorkflow\Client;
use DurableWorkflow\Worker;
$client = new Client('http://localhost:8080', token: 'WORKER_TOKEN');
$worker = new Worker($client, taskQueue: 'default');
$worker->registerWorkflow(MyWorkflow::class);
$worker->registerActivity(MyActivity::class);
$worker->run();from durable_workflow import Client, Worker, workflow, activity
client = Client("http://localhost:8080", token="WORKER_TOKEN", namespace="default")
worker = Worker(
client,
task_queue="default",
workflows=[MyWorkflow],
activities=[my_activity],
)
await worker.run()MIT