Skip to main content
Omniflow is a Go 1.26 module and one Next.js application, deployed as five containers plus a reverse proxy of the operator’s choosing. Nothing in the stack requires a broker, an orchestrator, or a plugin runtime, because the target deployment is a single Linux server run by one person who also runs the VPN panel. Each choice below is written with the alternative it displaces, so a contributor can tell which decisions are load-bearing and which are merely current.

Go, as one module with three entrypoints

go.mod declares go 1.26.0 and one module, github.com/omniflow/omniflow. cmd/api, cmd/bot, and cmd/worker compile from the same tree, so a domain change reaches all three at once and there is no version skew between an API that writes a row and a worker that reads it. The alternative — three services with three repositories and three deployment cadences — would buy independent scaling that no installation of this size needs, in exchange for a distributed protocol between checkout, entitlement, and provisioning. Instead, the boundary that matters is a PostgreSQL transaction. See Architecture overview. Reachability is a real constraint rather than a convention: go list -deps ./cmd/<name> shows exactly what each process can touch, and the api binary genuinely does not contain the campaign runner or the retention sweeper.

chi rather than a framework

github.com/go-chi/chi/v5 is a router over net/http, not a framework. Every handler is an http.HandlerFunc and every middleware is a plain func(http.Handler) http.Handler, which is why the three prefixes can carry three genuinely different middleware stacks in one process without any of them inheriting another’s. Two chi behaviours are load-bearing. middleware.RequestID assigns the identifier that ends up in the X-Request-ID response header, in every problem body as request_id, in audit rows, in fulfillment correlation IDs, and in customer security events — one string that ties a customer’s complaint to a handler log line and a Remnawave call. And chi.RouteContext(...).RoutePattern() gives the metrics middleware a route template to label with, so a path containing a customer identifier never becomes a Prometheus label value.

pgx, sqlc, and Atlas

pgx/v5 is used directly, with no ORM. Queries live as SQL in database/queries/ and sqlc generates typed Go into internal/database/dbgen with sql_package: pgx/v5. What that buys is that the SQL a reviewer reads is the SQL that runs — including the parts of PostgreSQL an ORM tends to hide, and which this codebase leans on heavily: ON CONFLICT … DO UPDATE … RETURNING for idempotent inserts, pg_advisory_xact_lock to make a concurrency ceiling hold under two simultaneous purchases, SELECT … FOR UPDATE inside serializable transactions, partial unique indexes, and CHECK constraints that make invalid states unrepresentable. Schema changes go the other way round from a migration-first tool: edit database/schema.sql, generate a reviewed Atlas migration with make atlas-diff, then regenerate sqlc. Migrations are forward-only — there are no down-migrations, and a schema rollback is a restore. database/migrations/atlas.sum is produced only by atlas migrate hash and is never hand-edited: a checksum mismatch means the migration changed, not that the file is stale. CI applies every committed migration from several baselines up to head and proves that re-applying head changes nothing, and the integration suite applies them into a Testcontainers PostgreSQL 18.4. See Database.

River for durable work

riverqueue/river v0.43.0 with the riverpgxv5 driver runs jobs in the same PostgreSQL database as everything else. That is the point: a job can be inserted with InsertTx inside the transaction that made the state change requiring it, which a broker in a separate system cannot offer without a two-phase protocol or an outbox dispatcher. AGENTS.md makes it a rule and forbids introducing a broker without an accepted architecture decision. Three queues are configured — critical (16 workers), default (8), and bulk (4). fulfillment and goods_delivery run on critical with MaxAttempts: 12; periodic_task, the whole-table passes, runs on default with one attempt.
River’s own tables ship with the Atlas migrations: 20260905130000_river_queue.sql is River’s own migration line copied verbatim, so the compose migrate service creates river_job and its siblings along with Omniflow’s schema. An integration test fails if rivermigrate reports an unapplied River version, so a River upgrade needs a matching migration.
Whole-table passes with no per-record argument are River periodic jobs rather than tickers, because a ticker runs once per process and River’s elected leader enqueues a periodic job once for the whole installation, so the worker can run as several replicas. Only loops that touch their own process stay on a time.Ticker. Both are inventoried in Jobs and queues.

PostgreSQL 18 and Valkey 9

PostgreSQL is the only durable source of truth. The compose stack pins postgres:18.4-alpine, and the bot and worker images carry postgresql18-client so pg_dump and pg_restore match the server’s major version — the Dockerfile warns against lowering the Alpine base below 3.23, which is the first release carrying that package. Valkey (valkey/valkey:9.1.1-alpine) is used for rate-limit counters, single-use replay tokens, locks, and cache, and is run with --save "" --appendonly no. Persistence is switched off deliberately: nothing in Valkey is allowed to be the only copy of anything. Both primitives are one Lua script each — Allow is INCR plus PEXPIRE, Once is SET NX PX — and both return an error rather than a permissive default when the client is missing, so an outage cannot silently remove a protection. Backups shell out to pg_dump --format=custom and pg_restore rather than reimplementing them, so a backup is always restorable with standard PostgreSQL tooling; the encryption is a framed AES-256-GCM wrapper around that stream. See Backup and restore.

OpenAPI 3.0.3 and generated bindings

api/openapi.yaml is the contract. It is OpenAPI 3.0.3 rather than 3.1 because the selected Go generator does not yet have stable 3.1 support, and AGENTS.md records that as the condition for moving. make generate runs three generators: go tool oapi-codegen for Go server types, go tool sqlc generate for the database layer, and bun run generate:api, which drives Orval twice — once for TypeScript types and SWR hooks, once for Zod schemas. CI regenerates all of it and fails on any diff, so a contract change committed without regeneration does not merge. The transport the web app actually uses is hand-written (packages/api-client/src/fetcher.ts) rather than generated, and this is deliberate and documented in the file itself: Orval 8.24’s SWR client emits bare fetch calls and ignores override.mutator, so a generated hook would send neither the session cookie nor the CSRF token — every mutation through one would be rejected and every read would be anonymous. The wrapper targets the same generated types, so request and response shapes stay checked against the contract; only the transport differs. It sets credentials: "include", re-reads the X-CSRF-Token response header after every call so a long-lived tab survives session rotation, leaves Content-Type alone for FormData because only the browser knows its own multipart boundary, and turns a problem response into an ApiError whose code is the last segment of the problem type. Contributors should know that the generated Go server types in internal/httpapi/apigen are currently imported by nothing: handlers decode into anonymous structs and write hand-built maps. Nothing validates a response against the schema at build or run time, so a schema change needs a test to be enforced.

The web application

One Next.js 16.3 application (apps/web) carries both surfaces: /account for customers and /admin for operators. One app rather than two means one component system, one localisation catalogue, and one build to keep green — and the route split is organisational only, since authorization is enforced by the Go API on every request. The production image builds with Bun 1.3.14 and serves Next.js standalone output from node:26.7-alpine on port 3000.

Tooling

Bun workspaces (apps/*, packages/*) handle installation and scripts, with bun.lock committed and no npm, pnpm, or Yarn lockfile alongside it — one lockfile means one dependency graph. Biome 2.5 is the only TypeScript and JavaScript formatter and linter, so formatting is never a review topic. TypeScript is 7.0, and generated clients must not depend on TypeScript’s removed programmatic APIs. Testing splits three ways by what each layer can prove. go test -race -cover ./... runs with no Docker daemon at all, because the Testcontainers suites live in internal/integrationtest behind the integration build tag. Those suites apply every committed migration into a real PostgreSQL and exercise the repositories. Playwright runs against a production build, on the grounds that a gate passing against a build nobody ships is not a gate.

Observability

log/slog writes JSON to stdout on the API and bot and to stderr on the worker, at the handler’s default level. There is no log-level, log-format, or log-destination environment variable anywhere in the repository, so raising verbosity is a code change. A shared redactor strips bot tokens, bearer and credential values, card-like digit runs, hwid and charge_id pairs, and any URL in scheme http(s), vless, vmess, trojan, or ss — replaced entirely, because even the host identifies the installation a customer connects to. The stated trade is that an over-redacted log line is a minor inconvenience while a leaked subscription link is a security incident. Prometheus metrics come from a per-process registry (not the global one), served at /metrics with a constant service label of api, bot, or worker. Every label is a bounded, non-identifying value, because label values are retained indefinitely by a scrape target. OpenTelemetry instrumentation is wired at four hops: otelhttp on the inbound router and the outbound client, otelpgx on the connection pool, a span per Telegram update, and a span per fulfillment job. otelpgx records statement text and never argument values; the outbound client records method and host only.
Omniflow never installs a tracer provider. otel.SetTracerProvider appears nowhere, no OTLP exporter is imported, and no OTEL_* variable is read by any process. The global provider is therefore a no-op and the spans described above are not exported anywhere. The instrumentation is in place; the exporter is not.
Observability lists the probes, the metrics that are actually written, and the log lines worth alerting on.

Deployment

compose.yaml at the repository root defines the whole single-server stack: postgres, valkey, a one-shot migrate service running arigaio/atlas:1.3.0, then api, bot, worker, and an optional web behind the web profile. Only api and web publish host ports; PostgreSQL and Valkey stay on the internal network. Images are built from build/go.Dockerfile with CGO_ENABLED=0 go build -trimpath. RUNTIME=static produces a distroless nonroot image for the API; RUNTIME=postgres produces an Alpine image with the PostgreSQL client for the bot and worker, which need it for backups. Released images are pushed to GHCR for linux/amd64 and linux/arm64 with provenance and an SBOM. No reverse proxy is bundled and none is required. deploy/proxies/ carries CI-validated Caddy and Traefik examples that terminate TLS, cap request bodies, set security headers, and — the part that matters — keep /metrics and /readyz off the public API host. Traefik’s example disables access logs, because a request path can be a subscription link. See Reverse proxy.

What is deliberately absent

  • A message broker. Domain events go through the transactional outbox instead, and AGENTS.md requires an accepted architecture decision before a broker is introduced.
  • A dependency-injection framework. Wiring is explicit in each main, which is verbose and readable; it changes when wiring complexity actually demands it.
  • An ORM. SQL is written, reviewed, and generated into types.
  • Kubernetes, Helm, and Pulumi. The only deployment topology in the repository is Docker Compose plus two proxy examples.
  • A plugin runtime. There is no WASM host, no plugin ABI, and no extension point that loads third-party code into a process.
  • A second place that decides what a role may do. The permission catalogue and role mapping are compiled into internal/rbac, so a database edit cannot widen access.

Architecture overview

The processes, the request paths, and the transactional pattern.

Development

Workspace setup, the generators, running the stack, and the checks.

Database

Schema, Atlas, sqlc, and the migration rules.

Deployment

Compose topology, volumes, upgrades, and rollback.