apps/workflow is the only durable-workflow executor and runs one replica. apps/service may scale independently.
dash / www
│
▼
apps/service (N replicas)
│ WorkflowControl over private HTTP
▼
apps/workflow (1 replica)
├─ /.well-known/workflow/* Workflow SDK executor
├─ / authenticated control commands
├─ /health
└─ workflows and steps
│
▼
shared PostgreSQL
├─ application tables
├─ workflow event log
└─ Graphile jobs
Both processes create the SDK World against the same storage, but only apps/workflow starts its Graphile runner.
| Operation | apps/service |
apps/workflow |
|---|---|---|
| Start, hook resume, cancel | Sends authenticated WorkflowControl commands |
Executes through workflow/api |
| Run status, hook readiness, stream reads | Reads shared World directly | Reads shared World directly |
| Runner, startup recovery, shutdown | Never owns them | Starts and closes them through the Nitro plugin |
Creating a World in service is safe because reads use only its storage and streamer. Every queue mutation crosses @chia/workflow-control/contract to the workflow process.
service resolves apps/workflow with withServiceEndpoint("/", Service.Workflow, { isInternal: true }). Railway uses INTERNAL_WORKFLOW_SERVICE_ENDPOINT; there is no public or NEXT_PUBLIC_* endpoint. Do not expose workflow ingress publicly.
Both apps must agree on:
WORKFLOW_TARGET_WORLD=@workflow/world-postgres
WORKFLOW_POSTGRES_URL=...
WORKFLOW_POSTGRES_JOB_PREFIX=...
WORKFLOW_QUEUE_NAMESPACE=... # when set
INTERNAL_WORKFLOW_SERVICE_TOKEN=<same value, at least 32 random characters>apps/workflow also owns worker settings:
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10
WORKFLOW_POSTGRES_MAX_POOL_SIZE=12Steps receive their database, provider and service credentials through apps/workflow/.env.example.
apps/workflow invalidates www's cache tags through the Vercel REST API with VERCEL_TOKEN, VERCEL_TEAM_ID and VERCEL_PROJECT_ID. An environment without them, such as beta, skips the call.
The installed @workflow/world-postgres adapter has process-local coordination:
world.start()re-enqueues open runs without a cross-process idempotency key. Hook-waiting agent sessions still count as running.- Replay and message deduplication live in memory. Graphile prevents duplicate claims of one job row, not duplicate jobs for one run.
runAgentTurnStepis not replay-safe and hasmaxRetries = 0; no database claim protects a step across processes.
Running two workflow replicas can therefore execute the same run or side effect twice.
Multi-replica support requires, in order:
- Producer and consumer separation.
- Leader-only startup recovery with stable idempotency identities.
- Postgres leases with fencing for replay and step execution.
- Idempotency keys or an outbox for external side effects.
- Keep
numReplicas: 1ininfra/railway/workflow.json. Brief overlap during deployment is tolerated; steady-state overlap is not. - When migrating from an embedded runner, first deploy
servicewithoutworkflow/nitro, then startapps/workflow. The event log and queued jobs remain in Postgres during the gap. - The Nitro plugin creates the Postgres World directly, registers it with
setWorld()and callsworld.start()at boot. WithoutsetWorld()the runtime builds a second World whose queue starts inside the first request. Give the container enough termination grace for in-flight steps. - Worker concurrency is per process. Keep the workflow pool at least
concurrency + 2, and include it in the database connection budget.
pnpm dev:service, dev:www and dev:dash start apps/workflow on port 3008 beside service. make init copies compatible local environment files and the shared control token.