Files
Peter Wielander 165c02979d [docs] Scope the Postgres World ingress guidance to the flow route
Blocking all of `/.well-known/workflow/` breaks `createWebhook()`, whose
`webhook/:token` route is a sibling under the same prefix and is meant to be
reachable by the caller. Also note that WORKFLOW_PUBLIC_MANIFEST is read at
build time, and attribute payload validation to the runtime rather than the
queue handler, which only checks the header shape and queue-name prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:58:56 -07:00
..
2025-10-23 12:07:52 +03:00
2026-09-09 12:12:11 -07:00
2025-10-23 12:07:52 +03:00
2026-09-09 12:12:11 -07:00
2025-10-23 12:07:52 +03:00

@workflow/world-postgres

An embedded worker and workflow system backed by PostgreSQL for multi-host self-hosted solutions.

This is a reference implementation. It shows how to implement the World interface on top of a database and a queue, and it is a reasonable starting point for self-hosting. It is not a managed backend: it ships without authentication, and it is not tuned for the performance or scale properties of one. A production deployment typically runs workers in separate processes with a more robust queuing system, and must put its own authentication in front of the workflow HTTP routes. Read Security before you deploy it.

While some customers have been successful in deploying the Postgres World as is, for production use-cases, we highly recommend cloning this reference implementation and adapting it to your persistence, network stack, scale and security requirements.

Security

The Postgres World does not automatically authenticate the requests that drive workflow execution. Adding that is your responsibility, and it should be in place before an app using this World is reachable by untrusted clients.

Protect the queue route

POST /.well-known/workflow/v1/flow is where the worker delivers workflow orchestration and queued step invocations. It is mounted in your application like any other route, so it is publicly reachable by default, and the queue handler, inherited from @workflow/world-local, accepts any request whose x-vqs-* headers and queue-name prefix are well-formed, leaving the payload itself to be validated later by the runtime that consumes the message. There is no signature, shared secret, or caller check, so anyone who can reach the route can forge or replay a workflow or step invocation, including steps your application would only reach after its own gating. Restrict it before you expose the app.

The other routes under /.well-known/workflow/v1/ differ:

  • webhook/:token, created by createWebhook(), is authorized by the token in the URL and nothing else. Use createHook() behind your own authenticated route and resumeHook() when you need more than that.
  • manifest.json responds with 404 unless the app was built with WORKFLOW_PUBLIC_MANIFEST=1. That variable is read at build time, so unsetting it in the runtime environment of an already-built deployment does not withdraw the manifest. Leave it unset outside of testing, because the manifest lists your workflow and step names.

Bringing your own auth

Workflow does not prescribe an auth mechanism, so gate the flow route at the network edge rather than inside the application:

  • Keep the flow route unreachable from outside. By default the worker delivers to a loopback address (http://localhost:{PORT}, or WORKFLOW_LOCAL_BASE_URL when set), so in the common single-process topology nothing outside the container needs to reach it. Blocking external requests to /.well-known/workflow/v1/flow at your ingress, reverse proxy, or firewall costs you nothing, because loopback delivery never traverses that layer. Do not block the whole /.well-known/workflow/ prefix if you use createWebhook(): its webhook/:token route sits under the same prefix and has to stay reachable by whoever calls it.
  • Authenticate at the proxy when the routes must cross hosts. If your web tier and workers are separate deployments, require mTLS or a shared-secret header at the proxy in front of the application, and strip any client-supplied copy of that header at the edge.
  • Do not gate these paths in framework middleware. The setup guides tell you to exclude /.well-known/workflow/* from your Next.js proxy matcher, because a handler that consumes the internal request body breaks execution. Adding the paths back in order to gate them reintroduces that failure mode.
  • Do not expect the World to present a credential. It does not sign its delivery requests or attach a secret to them, so an in-application check that requires one will reject the World's own deliveries.

Data at rest

This World does not currently implement getEncryptionKeyForRun(), so it does not participate in Workflow's end-to-end encryption: workflow and step inputs and return values, hook payloads and metadata, and stream chunks are all stored in your database in readable form. A World derived from this one can opt in by implementing that single method (see Custom World implementations), which is the recommended route if your workflows carry sensitive data. Until then, protect the database, its credentials, and its backups accordingly.

Installation

npm install @workflow/world-postgres
# or
pnpm add @workflow/world-postgres
# or
yarn add @workflow/world-postgres

Usage

Basic setup

The PostgreSQL World can be configured by setting the WORKFLOW_TARGET_WORLD environment variable to the package name:

export WORKFLOW_TARGET_WORLD="@workflow/world-postgres"

Configuration

Configure the PostgreSQL world using environment variables:

# Required: PostgreSQL connection string
export WORKFLOW_POSTGRES_URL="postgres://username:password@localhost:5432/database"

# Optional: Job prefix for queue operations
export WORKFLOW_POSTGRES_JOB_PREFIX="myapp"

# Optional: Worker concurrency (default: 10)
export WORKFLOW_POSTGRES_WORKER_CONCURRENCY="10"

# Optional: Internal pg.Pool max size (default: 10)
export WORKFLOW_POSTGRES_MAX_POOL_SIZE="10"

# Optional: Let the application coordinate shutdown (default: false)
export WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN="1"

# Optional: Maximum Hook minimum retention in days (default: 30)
export WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS="30"

Programmatic usage

You can also create a PostgreSQL world directly in your code:

import { createWorld } from "@workflow/world-postgres";

const world = createWorld({
  connectionString: "postgres://username:password@localhost:5432/database",
  jobPrefix: "myapp", // optional
  queueConcurrency: 50, // optional
  maxPoolSize: 10, // optional, overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE when `pool` is omitted
});

// Or pass an existing pg.Pool (shared with your app Drizzle, etc.); `world.close()` will not end it.
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const worldFromPool = createWorld({ pool });

Application-managed shutdown

By default, Graphile Worker responds automatically when the application is asked to shut down. If your application already coordinates shutdown, set WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN=1 when selecting the package with WORKFLOW_TARGET_WORLD, or set applicationManagedShutdown: true when calling createWorld() directly. Await world.close() from your shutdown path so Graphile Worker cannot terminate the process as soon as its queue stops, before your application finishes closing dependent resources:

import { createWorld } from '@workflow/world-postgres';

const world = createWorld({
  connectionString: process.env.DATABASE_URL!,
  applicationManagedShutdown: true,
});

await world.start();

Use this option only when your application or framework has its own shutdown hook. Handle cleanup errors there and await world.close() first, then close the workflow HTTP server and any caller-owned pg.Pool.

Closing the world stops the queue from accepting new jobs and waits for active jobs. After Graphile Worker's graceful-shutdown timeout (5s by default), it aborts any workflow HTTP request that is still pending. Graphile Worker then unlocks the same row through its normal failure handling. Graphile counts a delivery attempt when it claims the row, so the aborted delivery consumes that attempt and is retried only if its Graphile attempt budget remains. A one-attempt or final-attempt job is unlocked but not retried. The shutdown handler does not create a replacement row.

An aborted HTTP request does not guarantee that its server-side handler stopped, so workflow and step handlers must continue to tolerate at-least-once execution. Keep the workflow HTTP routes and any caller-owned pool available until world.close() resolves.

Configuration options

Option Type Default Description
connectionString string process.env.WORKFLOW_POSTGRES_URL, process.env.DATABASE_URL, or 'postgres://world:world@localhost:5432/world' Used only when pool is omitted, to construct an internal pool
maxPoolSize number process.env.WORKFLOW_POSTGRES_MAX_POOL_SIZE or pg.Pool default (10) Optional. Sets the internal pg.Pool max size when createWorld() creates the pool
pool pg.Pool Not applicable Optional. When set, used for Drizzle, Graphile Worker, and stream writes. world.close() does not end it.
jobPrefix string process.env.WORKFLOW_POSTGRES_JOB_PREFIX Optional prefix for queue job names
queueConcurrency number 50 Number of concurrent active step executions per process. Must be high enough to cover any parent→child workflow polling in flight because each Run#returnValue await holds a worker slot until the child run terminates.
applicationManagedShutdown boolean false; WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN=1 enables it for the default package configuration Whether the application coordinates shutdown and awaits world.close() instead of Graphile Worker responding automatically.

Environment variables

Variable Description Default
WORKFLOW_TARGET_WORLD Set to "@workflow/world-postgres" to use this world -
WORKFLOW_POSTGRES_URL PostgreSQL connection string DATABASE_URL or 'postgres://world:world@localhost:5432/world'
WORKFLOW_POSTGRES_JOB_PREFIX Prefix for queue job names -
WORKFLOW_POSTGRES_WORKER_CONCURRENCY Number of concurrent workers 50
WORKFLOW_POSTGRES_MAX_POOL_SIZE Internal pg.Pool max size 10
WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN Set to 1 when the application coordinates shutdown and awaits world.close() unset (false)
WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS Maximum Hook minimum retention in days 30

When pool is omitted, maxPoolSize precedence is: createWorld({ maxPoolSize }), then WORKFLOW_POSTGRES_MAX_POOL_SIZE, then the pg.Pool default.

For higher worker concurrency, Graphile Worker recommends setting maxPoolSize to 10 or queueConcurrency + 2, whichever is larger.

Database setup

This package uses PostgreSQL with the following components:

  • Graphile Worker: For queue processing and job management
  • Drizzle ORM: For database operations and schema management
  • pg (node-postgres): For PostgreSQL client connections. Drizzle and Graphile Worker share a pg.Pool, while LISTEN uses a dedicated pg.Client created from the same connection options.

Quick setup with CLI

Set up your database with the included CLI tool:

# npm
npx --package=@workflow/world-postgres bootstrap

# pnpm
pnpm dlx --package @workflow/world-postgres bootstrap

# Yarn
yarn dlx --package @workflow/world-postgres bootstrap

# Bun
bunx --package @workflow/world-postgres bootstrap

The CLI and runtime World automatically load the connection string from:

  1. WORKFLOW_POSTGRES_URL environment variable
  2. DATABASE_URL environment variable
  3. Default: postgres://world:world@localhost:5432/world

Database schema

The setup creates the following tables:

  • workflow_runs: Stores workflow execution runs
  • workflow_events: Stores workflow events
  • workflow_steps: Stores individual workflow steps
  • workflow_hooks: Stores webhook hooks
  • workflow_stream_chunks: Stores streaming data chunks

You can also access the schema programmatically:

import { runs, events, steps, hooks, streams } from '@workflow/world-postgres';
// or
import * as schema from '@workflow/world-postgres/schema';

Make sure your PostgreSQL database is accessible and the user has sufficient permissions to create tables and manage jobs.

Data retention

Postgres World does not yet perform general workflow-run cleanup. After a retained Hook's run ends and its deadline passes, reads treat the Hook as absent and its token can be reused. If the token is never reused, the expired workflow_hooks row remains.

Features

  • Durable storage: Stores workflow runs, events, steps, hooks, and webhooks in PostgreSQL
  • Queue processing: Uses Graphile Worker as the durable queue and executes jobs over the workflow HTTP routes
  • Durable delays: Reschedules waits and retries in PostgreSQL
  • Streaming: Real-time event streaming capabilities
  • Health checks: Built-in connection health monitoring
  • Configurable concurrency: Adjustable worker concurrency for queue processing

Queue behavior

  • Graphile jobs are acknowledged only after execution finishes, or after the worker durably schedules a delayed follow-up job
  • Backlog stays in PostgreSQL when all execution slots are busy
  • Retry and sleep-style delays use Graphile runAt scheduling
  • Workflow orchestration and queued step execution are both sent through /.well-known/workflow/v1/flow

Development

For local development, you can use the included Docker Compose configuration:

# Start PostgreSQL database
docker-compose up -d

# Create and run migrations
pnpm drizzle-kit generate
pnpm drizzle-kit migrate

# Set environment variables for local development
export WORKFLOW_POSTGRES_URL="postgres://world:world@localhost:5432/world"
export WORKFLOW_TARGET_WORLD="@workflow/world-postgres"

Testing

Integration tests use Testcontainers to start a PostgreSQL container. Docker must be installed and running before you run tests.

  • Linux/macOS: Start the Docker daemon (e.g. sudo systemctl start docker or Docker Desktop).
  • WSL2: Use Docker Desktop with WSL2 integration, or run the Docker engine inside WSL and ensure the daemon is started. Verify with docker info.

Then from the package directory:

pnpm build
pnpm test

World selection

To use the PostgreSQL world, set the WORKFLOW_TARGET_WORLD environment variable to the package name:

export WORKFLOW_TARGET_WORLD="@workflow/world-postgres"