OpenSequence
DocsAPI referenceSDKs and CLIReference
Self-hosting

Database

Postgres 14+, the seq and auth schemas, migrations on boot, poolers, backups and restore.

Edit on GitHub
SEQ_DB_DSN=postgres://oseq:secret@db.internal:5432/opensequence   # the only database setting
SEQ_DB_POOL=10                                                     # connections per process
SEQ_RUN_MIGRATIONS=1                                               # apply migrations at boot

Requirements

Postgres 14 or newer. No extensions: ids are generated by the service (prefixed ULIDs), timers are rows, the scheduler uses lease rows, and the postgres queue is a table with FOR UPDATE SKIP LOCKED. Session-level features (advisory locks, LISTEN, prepared statements across transactions) are not used, so a transaction pooler such as PgBouncer in transaction mode works.

The schemas

The service creates and owns two schemas:

SchemaOwnerTables
seq@opensequence/engineprojects, api_keys, webhook_endpoints, webhook_deliveries, idempotency_keys, senders, sender_channels, contacts, suppressions, sequences, versions, enrollments, runs, messages, tasks, signals, signal_effects, flags, linkedin_connections, job_locks, queue_messages, queue_dead, migrations, migration_lock
auth@opensequence/authcredentials (a project's provider secrets), connections (a sender's OAuth tokens), plus its own migration bookkeeping

Every tenant-owned table carries project_id. Secrets in auth are AES-256-GCM ciphertext under SEQ_ENCRYPTION_KEYS, each row tagged with the key id that encrypted it.

Migrations

Migrations are SQL files generated by Drizzle from the TypeScript schema and shipped in the image. With SEQ_RUN_MIGRATIONS=1 every process applies them at boot: one transaction per migration, serialized by SELECT … FOR UPDATE on seq.migration_lock, so replicas starting together do not race and a pooler is fine. Applied migrations are recorded in seq.migrations by tag and content hash; a file changed after it was applied is refused rather than silently re-run. Running twice is a no-op. oseq migrate does the same from the CLI (node dist/cli.js migrate in the image), for a pipeline that migrates before deploying. /readyz reports migrations pending until the process has applied them.

Pool size

SEQ_DB_POOL (default 10) is per process. The worker uses connections for the scheduler jobs plus SEQ_CONCURRENCY in-flight messages per queue (four queues by default: seq_email, seq_sms, seq_signals, seq_webhooks); a pool of 10 serves the default concurrency of 8 because most work is short. Behind a pooler, size the pooler for replicas × SEQ_DB_POOL.

Backups and restore

Back up the database and SEQ_ENCRYPTION_KEYS together: a restored database without the key that encrypted its secrets has unreadable credentials, and every Google client secret, refresh token, Allo and Attio key and webhook secret must be entered again (senders reconnect Google). A plain pg_dump of the database is enough; there is no state outside it except the queue when SEQ_QUEUE=redis (which loses nothing on restore, since the reaper rebuilds work from the tables).

Growth

The ledgers (runs, messages, signals, webhook_deliveries) grow with activity: a few rows per enrollment per step. The daily retention job prunes idempotency keys after a day and dead-queue rows and delivered deliveries after 30 days; runs, messages and signals are kept (the retention_days project setting is accepted but not enforced yet, see Data retention). At the volumes the service targets (tens of thousands of runs a day) the analytics queries read these tables directly with the shipped indexes.

On this page