cmd/api, cmd/bot, cmd/worker — built from the
same source tree into three images, plus one Next.js application that carries both browser surfaces. They
share one PostgreSQL database and one Valkey instance. PostgreSQL is the only durable source of truth;
Valkey holds cache, rate-limit counters, locks, and temporary state, and the compose stack runs it with
--save "" --appendonly no so nothing is tempted to treat it as storage.
The split between the three processes is about failure isolation and lifetime, not about scale. A batch of
four hundred subscription changes must not be tied to the lifetime of the HTTP request that started it,
and automatic renewals — the one place money moves with nobody watching — run where no request can cancel
them.
The four processes
The three Go images differ only in what they need at runtime:
api ships on a distroless base, while
bot and worker ship on Alpine with postgresql18-client, because both shell out to pg_dump and
pg_restore. The web image serves Next.js standalone output from a Node base. See
Deployment.
What each process refuses to start without
Configuration is read from environment variables once, at startup, and a bad value is fatal rather than latent. Nothing re-reads the environment, so every change is a restart.apiboots with an emptyAPP_DATABASE_URLand then serves only/healthz,/livez,/readyz, and/metrics. With a database URL set it additionally requiresAPP_OPERATOR_TOKEN, a 32-byteAPP_DATA_ENCRYPTION_KEY,APP_REMNAWAVE_URL,APP_REMNAWAVE_TOKEN, andAPP_VALKEY_URL, or it exits withcommerce requires APP_OPERATOR_TOKEN, APP_DATA_ENCRYPTION_KEY, APP_REMNAWAVE_URL, APP_REMNAWAVE_TOKEN, and APP_VALKEY_URL.botwith an emptyAPP_TELEGRAM_TOKENlogstelegram bot disabled because APP_TELEGRAM_TOKEN is emptyand idles until it is signalled — that is a supported configuration, not an error. With a token it requiresAPP_DATABASE_URL,APP_VALKEY_URL,APP_REMNAWAVE_URL, andAPP_REMNAWAVE_TOKEN. Valkey is mandatory here because rate limiting and callback replay protection both guard money-moving taps.workerrequiresAPP_DATABASE_URL,APP_REMNAWAVE_URL, andAPP_REMNAWAVE_TOKEN.APP_DATA_ENCRYPTION_KEYis optional: without it the worker logsdigital goods delivery disabledand runs everything else.
How a request travels
Every entry point lands in PostgreSQL first. Only the worker talks to Remnawave on a write path, and it does so from a durable job rather than from a request. A purchase makes the shape concrete:1
The customer picks a plan
In the browser this is
/account and the /v1/account/checkout routes; in Telegram it is the bot’s
catalogue screens. Both create an ordinary order row.2
A payment intent is created
The order gets an intent against one provider adapter. The customer is sent to the provider, or pays
in Telegram, or the order settles against their wallet balance.
3
The provider calls back
POST /v1/payments/webhooks/{provider} is unauthenticated by necessity and protected by signature
verification over the exact bytes the provider sent. A duplicate provider event resolves to
{"status":"duplicate"} rather than settling twice.4
Settlement, entitlement, and job commit together
In one transaction the API marks the order paid, writes the entitlement, creates the
fulfillment_operations row with idempotency key order:<orderID>:fulfill, writes an order.paid
outbox event, grants any referral reward, and inserts the River job.5
The worker provisions
The
fulfillment worker picks the job up, opens a serializable transaction, locks the operation row,
and calls Remnawave. On success the previous entitlements of that subscription are superseded and the
order moves to fulfilled.6
The customer sees access
The access link is read live from Remnawave on every request. It is never stored in Omniflow.
Three API prefixes, three trust models
The HTTP API is split into three prefixes that never share a router.
Merging any two would mean one silently inheriting the other’s middleware, and a customer credential must
never be able to reach an operator route. Keeping them structurally apart makes that a property of the
route table rather than a check somebody has to remember; a test asserts that the customer router answers
404 for /v1/panel/auth/session, /v1/admin/orders, and /v1/panel/admins.
A prefix whose service is not attached is absent — 404, not 503 — because a 503 would tell an
anonymous caller what the operator has configured. The exception is the pre-sign-in routes, which answer
503 panel_unavailable or 503 account_unavailable, since they run before any credential exists.
The process-wide middleware applies to all three: request ID, panic recovery, a 60-second handler timeout,
OpenTelemetry, and metrics. /v1/admin is an external automation surface — nothing in this repository
calls it, and the bot talks to PostgreSQL and Remnawave in process rather than over HTTP. See
Integrating with the API.
The web route boundary is organizational, not a security boundary. Authorization is enforced in the Go API
on every request, and /admin server components check the same permissions before rendering; a separate
hostname or hidden navigation is never sufficient authorization. The permission catalogue and role mapping
are compiled into internal/rbac, so the panel renders from exactly the set the API enforces and a
database edit cannot widen access.
A state change and its durable job commit together
The rule inAGENTS.md is short: enqueue durable River jobs in the same PostgreSQL transaction as the
state change that requires them. That is what makes “a paid order always has a job waiting for it” true
rather than hopeful, and an integration test exists whose entire point is that property.
The enqueue hook is a function passed into the commerce store at process start, so the same settlement
code path is transactional in the API and in the worker. The bot deliberately passes no hook: it creates
orders and payment intents, but provisioning belongs to the worker and settlement is committed by the
process that received the callback.
Retrying is safe because the durable record, not the queue, carries identity. fulfillment_operations
has a UNIQUE idempotency key, and creating one is ON CONFLICT (idempotency_key) DO UPDATE … RETURNING,
so a replay reads back the operation that already exists. Reusing a key with different parameters is
refused outright with idempotency key was already used with different fulfillment parameters. The known
key shapes are order:<orderID>:fulfill, order:<orderID>:addon, gift:<giftID>:fulfill,
reconcile:<entitlementID>:<15-minute bucket>, panel:<subscriptionID>:<operation>:<requestID>, and
bulk:<operationID>:<position>.
Two job kinds carry per-record work — fulfillment and goods_delivery — both on the critical queue
with MaxAttempts: 12 and uniqueness by job arguments; a third, periodic_task, carries the
periodic passes on default. The three configured queues are
critical (16 workers), default (8), and bulk (4). Workers are registered only in the worker process;
the API and the bot create insert-only River clients.
River’s own tables —
river_job, river_leader, river_queue, river_notification, river_migration —
are created by the committed Atlas migration 20260905130000_river_queue.sql, a verbatim copy of River’s
own migration line, so the compose migrate service leaves a database the worker can start on. See
Jobs and queues.The transactional outbox
Three topics are written tooutbox_events inside the transaction that caused them: order.paid,
wallet.credited, and addon.purchased. The row carries a topic and a payload and is written with the
same all-or-nothing guarantee as the state change beside it.
Nothing dispatches them. No code path sets outbox_events.published_at; the only statements that touch
the column are two WHERE published_at IS NULL reads and the retention delete, which matches
published_at IS NOT NULL. In practice that means the outbox is a durable record of what happened rather
than a delivery mechanism: GET /v1/admin/outbox reports a pendingCount that only grows,
APP_RETENTION_OUTBOX_DAYS removes nothing, and the panel’s outbox tab at
GET /v1/panel/system/outbox shows topic and age only — never the payload, which can name a customer and
an amount.
Periodic passes and in-process loops
Every whole-table pass that changes state — order, wallet and cart maintenance, payment reconciliation, renewals, reconciliation and delivery scheduling, the lifecycle sweep, anomaly evaluation, campaign expansion, bulk actions, retention, and backups — is a River periodic job of kindperiodic_task on the
worker. Only River’s elected leader enqueues them, and each stays unique while it is queued or running, so a
pass runs once per interval across the installation however many workers are up. A pass gets one attempt: a
failure is recorded and the next interval runs a fresh pass over the same rows.
A few loops stay plain in-process tickers because they touch only their own process or are harmless to
repeat: the API’s maintenance controller, telemetry heartbeat and collector retention, and the bot’s
channel-membership check and notification drains. Only the maintenance, retention, and backup intervals are
configurable; the rest are compiled in. The inventory is in
Jobs and queues.
Supported topology
Run any number ofapi replicas behind the proxy and any number of worker replicas, and exactly one
bot: Telegram delivers updates to one consumer. Each worker replica needs the shared backup and attachment
directories, and every replica’s database pool counts against PostgreSQL’s max_connections. See
Deployment.
The compose stack declares no restart: policy on postgres, valkey, api, bot, worker, or web;
only the Traefik overlay sets one. A crashed process stays down until somebody notices, which is what the
probes and Observability are for.
Why a modular monolith
AGENTS.md states the rule — keep the Go application a modular monolith with independent api, bot,
and worker entrypoints — and four supporting constraints make it hold:
- Domain packages must not import transport, database-generation, Telegram, or provider-specific packages. The dependency arrows point one way.
- External systems sit behind small interfaces. Remnawave is reached only through
remnawave.Service,remnawave.Provisioner, andremnawave.Importer, so the only Remnawave-shaped types in the codebase live in one package. - Domain events go through the transactional outbox, and no message broker is introduced without an accepted architecture decision.
- Dependency construction stays explicit, with no dependency-injection framework until wiring complexity demands one.
.env, and three containers to
run. What a contributor gets: a change that spans checkout, entitlement, and provisioning is one
transaction and one test, not a distributed protocol. This supports hundreds of independent installations
without imposing a broker, Kubernetes, or a plugin runtime on any of them.
Where to go next
Data ownership
The exact Remnawave boundary: what each system is authoritative for, and what breaks when that is
violated.
Technology
Every component of the stack and the reason it is there.
Jobs and queues
Job kinds, retries, dead letters, scheduled loops, and retention sweeps.
Deployment
Compose topology, volumes, migrations, and upgrades.