Deploying OpenReceive

Every web instance needs the same receive-only NWC configuration and access to your database. NWC (Nostr Wallet Connect) is how the server talks to your wallet. Attempt rows, the settlement claim, and the scan gate all live in the database. There is no separate OpenReceive service to deploy.

Requirements

Stack Floor
Node (Express, Fastify, Next.js) Node ≥ 22; the App Router on Next.js ≥ 15
Rails Ruby ≥ 3.2, Rails ≥ 8.0; PostgreSQL, SQLite or MySQL (mysql2/trilogy)
FastAPI Python ≥ 3.10, FastAPI ≥ 0.115 (Starlette ≥ 0.40), a sync SQLAlchemy 2 Engine; PostgreSQL, SQLite or MySQL
Django Python ≥ 3.10, Django ≥ 5.2; PostgreSQL, SQLite (transaction_mode: IMMEDIATE) or MySQL ≥ 8.0.16 / MariaDB ≥ 10.2.7
PHP (plain) PHP ≥ 8.2, 64-bit, with ext-gmp (required by the NWC transport), ext-sodium, ext-mbstring, ext-pdo + pdo_pgsql/pdo_sqlite/pdo_mysql. PHP-FPM or Apache in front of one front controller. php -S is a development server only
Laravel PHP ≥ 8.2, 64-bit, Laravel ≥ 11, with ext-gmp (required by the NWC transport), ext-sodium, ext-mbstring, ext-pdo + pdo_pgsql/pdo_mysql/pdo_sqlite. PostgreSQL, MySQL/MariaDB or SQLite. PHP-FPM or Apache. php artisan serve is a development server only
BTCPay Server plugin BTCPay Server ≥ 2.4.4

Every stack needs the same two things at runtime:

It needs nothing else: no Redis, no queue, no OpenReceive service.

Multi-instance semantics

You can run as many web instances as you like. The database makes concurrent creates for the same order run one at a time. Settlement is written once and never changed. After a restart, or when instances overlap, they repeat limited passes that are safe to run twice. A payment is never fulfilled twice.

Each process caches rates and provider weights in memory. These caches only save time. Restarting may cause extra calls, but never loses anything stored in the database.

The durable scan gate

By default, settlement happens during normal requests. The mounted OpenReceive routes settle pending invoices as payers use them. The optional notifications worker below is the only separate process.

When attempts are pending, every mounted OpenReceive payment route runs one reconcile pass. GET /rates is the exception. A reconcile pass checks the wallet for payments to pending invoices. All open tabs share that pass. So if one payer closes the tab, another payer’s later request can settle the first invoice.

You do not need a cron job. An unpaid attempt is only closed after a successful wallet scan at or after its expiry. The local clock alone never closes it.

Worker topology

No background process is required. You can optionally add one:

The workers use the same gate and the same write-once settlement path as the pass that runs during requests.

When your application boots

If the NWC connection is missing or can spend funds, OpenReceive refuses to take payments.

On Node, the adapters check the wallet on the first request. Await that check in a deploy health check. Express and Next expose a ready promise. On Fastify, use await fastify.ready().

The Rails engine checks the wallet when the app boots in production. So a bad NWC_URI stops the deploy instead of showing customers a 500. Asset precompilation skips that check, because secrets are not mounted yet. For any other boot without secrets, set config.eager_preflight = false. That turns off only the boot check. The wallet is still checked on the first request.

On FastAPI, the check runs in the lifespan. FastAPI(lifespan=openreceive_lifespan(host, engine=engine)) runs the receive-only preflight when uvicorn starts. If it fails, it stops the process, so the deploy fails instead of the first payer. lazy=True delays the check to the first request, which helps in tests and build steps without secrets. Until the wallet passes, requests get 503 WALLET_UNAVAILABLE.

On Django, the wallet client is built lazily on the first request. It is NEVER built in AppConfig.ready(). That method runs for migrate, collectstatic and shells, and a relay probe there would break steps that run without secrets. To get the same early check that Rails does in production, put the preflight in your deploy pipeline:

Until the first request passes, the mounted routes answer 503 WALLET_UNAVAILABLE.

Where boot failures go

If boot fails, later requests answer 503 WALLET_UNAVAILABLE, and await ready rejects. On the Node adapters, pass onBootFailure to send that one line to your logger.

Node in Docker

OpenReceive does not generate a Dockerfile for you. These are the rules that matter:

Prisma’s CLI loads .env automatically for every command. That includes commands you run on the host outside Docker. So a container-path DATABASE_URL (file:/data/shop.db) in .env silently breaks prisma migrate deploy on the host. Keep two URLs. Put the host path in the dockerignored .env, and inject the container path at runtime. Never use one in place of the other.

Rails in Docker

openreceive-rails → nwc-ruby → rbsecp256k1 compiles libsecp256k1 from source. So slim images need the autotools in the build stage. Without them, bundle install fails with autoreconf: not found:

RUN apt-get update && apt-get install -y autoconf automake libtool build-essential pkg-config

Laravel in production

Under PHP-FPM or Apache, PHP boots your app fresh on every request. The engine’s Service runs a receive-only wallet preflight when it is constructed, so that check would run on every request. To avoid this, the package stores the wallet’s info event in your app’s default cache store for config('openreceive.wallet_info_cache_seconds') (600 by default). So a checkout request costs one relay round trip.

Rails runs an early preflight in production. Here, the same check runs on each web boot that misses the cache. It never runs in php artisan config:cache, migrate, or the other build commands that run without secrets.

Python (FastAPI) in production

The engine is synchronous on purpose. One wallet RPC is one blocking call. An async engine would be a second settlement implementation to maintain. So the router’s endpoint runs in Starlette’s threadpool. That is the same pool def endpoints use, with about 40 threads by default.

A slow relay holds a thread for at most the service’s wallet deadline (10 s). That deadline is the limit, and OpenReceive has no setting for the pool. If a busy shop needs more room, do one of these:

Behind a reverse proxy, run uvicorn with --proxy-headers. Then request.client is the payer, not the proxy. rate_limiting counts by request.client.

The same secret rules as Node apply to the image: never COPY .env, never an ENV NWC_URI. Inject secrets at runtime. Install the engine with uv or pip on Python ≥ 3.10 (pip install "openreceive[fastapi]"). Migrate with the Alembic revision that openreceive scaffold payments --alembic generates. Run it in the entrypoint, not at build.

Python (Django) in production

manage.py migrate applies the engine’s shipped migration along with your own. Run it in the entrypoint, not at build.

Operational monitoring

attention rows need a person to look at them. Payers never see this status. To them, the checkout still looks pending or expired. So alert on these rows internally:

SELECT reference, payment_hash, status_reason, expires_at
FROM openreceive_payments
WHERE status = 'attention';

Check each one in the wallet. If it actually settled, the next reconcile pass records it. If it is stuck, resolve it in the wallet.

See Payment storage and Rate limiting.

WordPress + WooCommerce

Build the plugin zip with npm run release:wordpress:build and upload it. The archive includes its PHP dependencies and browser assets. Configure the wallet in WooCommerce payment settings or in server-only constants. Attempts are stored in the existing WordPress MySQL/MariaDB database. On quiet shops, set up a system cron to run Action Scheduler. You can also run wp openreceive notifications as an optional separate process. See the WooCommerce quickstart.