Self-hosting
What the engine consists of, what it needs from you, and how to upgrade it without a maintenance window. It is two Node processes and one Postgres database — deliberately, so that operating it does not require operating a cluster.
Processes
| Process | Command | What it does | Scale |
|---|---|---|---|
| Operator API | npm run start:api | Serves the signed HTTP API and the health endpoints. Stateless between requests. | Horizontally. Every guarantee is in the database, not in a process. |
| Worker | npm run start:worker | Webhook relay, treasury float checks, reconciliation sweeps. Each loop is isolated from the others. | More than one is safe — work is claimed with FOR UPDATE SKIP LOCKED. |
| Console | npm run start:web | This site and the operator console. Signs on the server so the credential never reaches a browser. | Optional. It is a read surface. |
| Admin | npm run start:admin | Platform admin across operators. Read-only. | Optional. |
Environment
Configuration is read once at boot and validated as a whole. A misconfigured process refuses to start and names every variable that is wrong in one message — not the first one, and never the value.
| Variable | Required | Notes |
|---|---|---|
PAYMENTS_DATABASE_URL | Yes | Standard Postgres URL. The pool sets idle_in_transaction_session_timeout so a leaked transaction cannot hold a lock forever. |
PAYMENTS_PORT | No — 8080 | Refused rather than clamped if outside 1–65535. |
PAYMENTS_OPERATORS | Yes | id:secret, comma-separated. Held by the host process only. |
PAYMENTS_MIGRATIONS_DIR | No | Overrides migration discovery. Useful when the engine runs from a bundle rather than the repository. |
PAYMENTS_ENV | No — development | Appears in logs and on the console so nobody mistakes staging for production. |
PAYMENTS_WORKER_PORT | No — 8081 | The worker serves its own health endpoint. |
Platform Admin
The admin surface reads every operator's position, so it has its own sign-in rather than relying on nobody finding the port.
| Variable | Required | Notes |
|---|---|---|
PAYMENTS_ADMIN_USERS | Yes | username:$argon2id$… entries, separated by ; or a newline. Not commas — an argon2 digest contains them. With none set, nobody can sign in and the form says so. |
PAYMENTS_ADMIN_SESSION_KEY | Yes when users exist | At least 32 characters and independent from PAYMENTS_ADMIN_SECRET. Reuse is refused. |
PAYMENTS_ADMIN_SECURE_COOKIES | Yes in production | Must be true in production. It adds Secure, which makes the cookie unusable over plain http. |
PAYMENTS_ADMIN_SETTINGS_PATH | No | Where this surface keeps its own preferences. A read-only filesystem degrades to defaults rather than failing. |
$ npm run admin:hash --workspace @payments/admin
Password for the admin account: (typed, not echoed)
alice:$argon2id$v=19$m=65536,t=3,p=4$…PAYMENTS_ADMIN_USERS and restarting invalidates their session immediately, because a session naming an account that is no longer configured is not a session.401 that looks like a configuration fault and is really a collision. Separate credentials also give you separate audit trails and independent revocation, which you want anyway.# One per surface, not one shared between them. PAYMENTS_OPERATOR_APP00001_SECRET=… # your application PAYMENTS_OPERATOR_CONSOLE01_SECRET=… # the operator console PAYMENTS_OPERATOR_ADMIN0001_SECRET=… # the platform admin
[redacted] — so a stack trace, a log line or an accidental console.log cannot leak one. Reading it requires an explicit .reveal(), which is greppable.Migrations
Applied at boot, inside a transaction, under pg_advisory_xact_lock — so ten instances starting simultaneously produce one migration run and nine waits. Checksums are recorded: an already-applied migration that has since been edited stops the boot rather than silently diverging from what the database actually has.
Every migration is expand-only. A column is added, backfilled and read before anything stops writing the old one, and the contract half ships in a later release. This is enforced by a lint rule in CI, and the practical consequence is that a deploy can be rolled back without a reverse data migration.
$ npm run migration:lint ✓ 76 migrations, expand-then-contract respected ✓ no destructive statement outside a _contract file
Health checks
| Endpoint | Meaning | What to wire it to |
|---|---|---|
GET /health/live | The process is running and its event loop is responsive. | Restart policy. A failure here means kill and restart. |
GET /health/ready | Dependencies answer — the database in particular. | Load balancer. A failure means take out of rotation, do not restart. |
Upgrading without a window
- Deploy the new version alongside the old. Expand migrations run at boot and are safe for the old code to keep running against.
- Shift traffic. Both versions read and write the same schema by construction.
- Retire the old instances.
- The contract migration ships in a later release, once nothing is left that reads the old shape.
Backups
The database is the system of record for everything except the chain itself. A standard pg_dump plus WAL archiving is sufficient and there is nothing else to back up — no local state files, no queue to drain, no in-memory position to lose.
Observability
Every process writes one JSON object per line to stdout. Each request carries a requestId that also appears in the error envelope returned to the caller, which is how an opaque 401 is diagnosed: the caller quotes the ID, and the log says which of the four causes it actually was.
{"level":"warn","event":"auth.rejected","reason":"timestamp_outside_window",
"operatorId":"op_abcdefgh","skewSeconds":184,"requestId":"3c1ac997-…"}Clock skew is the most common cause in practice, and it is the one that produces intermittent failures rather than consistent ones. Run NTP on anything that signs.