Skip to main content
Omniflow is configured entirely from environment variables. There is no configuration file, no command-line flag other than the worker’s --decrypt-backup, and no live reload. internal/config exposes three loaders — one for the API, one for the bot, one for the worker — and each process runs its loader once, at startup. A value that fails validation is fatal rather than latent: the process logs invalid configuration and exits with status 1, so an installation never runs half-configured with a YooKassa shop ID and no secret, or backups enabled and no key. The consequence of loading once is that changing a variable requires restarting the process that reads it. Docker Compose passes the same .env file to api, bot, and worker, so in practice one file configures all three and a change means docker compose up -d again. Copy .env.example to .env to start; see Quickstart for the first run and Deployment for the compose topology. The tables below list every variable any Omniflow process reads, including a few that are absent from .env.example, and one that .env.example sets but nothing reads. Read by names the processes whose loader reads the variable; a variable read by a process that never uses the value is called out in the text under its table.

How values are parsed

Every variable goes through one of a small set of readers, and each has a distinct failure mode worth knowing before you debug a setting that appears to be ignored.
A boolean typo is invisible. APP_METRICS_ENABLED=yes and APP_TELEMETRY_ENABLED=nope are both rejected by the parser and both fall back to the default, which is true in each case. Use true and false.

What each process needs to start

The bot refuses to start without Valkey rather than running unprotected: Valkey backs its per-user rate limits and its callback replay guard, and both stand between a duplicate tap and a duplicate charge. The error says so — APP_VALKEY_URL is required for bot rate limits and callback replay protection.

Core

APP_DATA_ENCRYPTION_KEY is the key that seals customer contact values, operator TOTP secrets, panel-stored settings and OIDC secrets, the customer sign-in flow cookie, digital-goods provider credentials, and referral contact fingerprints. Generate it once:
Back the key up with the database and never rotate it casually. Changing it makes every value already sealed with the old key unreadable, and no process will offer to re-encrypt them.
The bot and the worker each start without it and disable one capability rather than refusing to run. A worker without the key logs digital goods delivery disabled with reason: APP_DATA_ENCRYPTION_KEY is not set, and additionally runs without the sweeper’s blocklist refresh and anomaly evaluation and without the bulk-action runner, because all three unseal a credential the panel wrote. Set it on the worker as soon as you sell digital goods, subscribe to a blocklist, or use bulk actions.

Database and cache

The backup service parses APP_DATABASE_URL a second time, into PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, and PGSSLMODE, so the password reaches pg_dump through the environment and never through the child process’s argv where any local user could read it. The worker’s loader reads APP_VALKEY_URL and the worker then uses nothing from it — no worker code path opens a Valkey client. Leaving it set is harmless and keeps one .env valid for all three processes.
The compose migrate service hard-codes postgres://omniflow:omniflow@postgres:5432/omniflow?sslmode=disable and does not read .env. Changing the database credentials means editing compose.yaml as well.

Remnawave

The API’s readiness probe calls Remnawave with an authenticated request that lists one user, so a revoked or expired token shows up as an unhealthy remnawave dependency in /readyz rather than as a failed purchase later. Three consecutive unhealthy samples also open maintenance mode by default. See Remnawave integration.

Telegram

Long polling is the default and stays supported as a development and fallback mode. Setting the webhook URL makes the bot call setWebhook on start with allowed_updates limited to message, edited_message, callback_query, pre_checkout_query, my_chat_member, and chat_member. Generate the secret with openssl rand -hex 32. Full setup, including BotFather and the Mini App domain, is in Telegram integration.

URLs and currency

APP_UPDATE_FEED_URL has no default, and that is the point. Reading a feed tells whoever serves it that this installation exists, roughly where it is, and which version it runs, so the check is something an owner turns on rather than something they discover they have been doing. Omniflow ships no address to compare against either: the documentation names no repository, and a URL baked into the binary would be a fact nothing else in the installation could corroborate. Set it to any JSON document naming a version — a GitHub releases endpoint answers tag_name, and an owner publishing their own file can answer version. Both shapes are read. The result appears in the diagnostics bundle as update, and the feed is read at most once every six hours however often the bundle is generated. A feed that refuses, times out, or answers with something unreadable reports unreachable rather than “up to date”. The detail is a category and never the address, because a feed URL can carry a token. | APP_DEFAULT_CURRENCY | RUB | api, bot, worker | no | no | Settlement currency the catalogue compares plans in. Uppercased, then matched against ^[A-Z]{3}$; anything else fails startup | Two startup refusals depend on APP_PUBLIC_URL, and both exist because the alternative is a link that leads nowhere: the bot refuses to start with YooKassa configured and no public URL (APP_PUBLIC_URL is required for YooKassa hosted checkout return links), and both the bot and the API refuse when APP_CUSTOMER_MAGIC_LINK_ENABLED=true and no public URL is set. Operator passkeys are the third thing that depends on it, and the one that does not refuse to start. A passkey is bound to an origin, so without a public URL there is nothing to bind one to and the feature is simply off: the sign-in screen omits the button rather than offering one that opens nothing. The consequence to plan for is the reverse — changing this value after operators have registered keys invalidates every one of them, because a browser will not offer a credential to a site it was not created for. See Security. Use XTR for a Telegram-Stars-only installation. A plan is only purchasable through a payment method that settles one of the currencies the plan is priced in, so price a plan in XTR to sell it through Stars and in a fiat currency to sell it through CryptoBot or YooKassa. See Catalogue and promotions.

Payment providers

An adapter exists only when its credentials are configured, which is why there is no separate enable switch. The YooKassa pair must be set together: the bot refuses to start with one without the other (APP_YOOKASSA_SHOP_ID and APP_YOOKASSA_SECRET must be configured together), because a shop ID with no secret is an installation that offers a payment method it cannot complete. The worker holds the same credentials as the API because it is the process that charges automatic renewals — a worker without an adapter could only ever record that a renewal failed. Per-provider setup is in Payment providers and the recurring flow in Recurring payments.

Wallet top-up and subscription limits

Amounts are minor units of APP_DEFAULT_CURRENCY. A preset the limits would refuse is never rendered, so a customer cannot tap a button the backend then rejects.
These variables are what the running processes enforce. On its first start against a database with no commerce_settings row, the API also seeds that row from these values, so an installation upgrading from an older version keeps the limits its operator configured rather than silently taking schema defaults. Afterwards the row is only written by the panel (PUT /v1/panel/settings/commerce/topup and .../subscriptions), and the stored row is what the panel and the customer web overview display — but order and wallet enforcement still comes from the environment values loaded at startup. Keep the variables set and in step with the panel, and restart the processes after changing either.
Seeding never blocks startup: a failure is logged as commerce settings could not be seeded; the panel will show none yet and the API carries on. See Wallet top-up and carts and Subscriptions.

Customer communication

The recovery window and the trial age control are read by the API as well as the bot on purpose: the customer web panel checks out against the same rules the bot does, and a trial a chat refuses while a browser grants it would be an abuse control with a hole in it. Set them identically for both processes, which one shared .env does automatically. Marketing caps and the cart lifetime are read only by the bot, because it is the process that sends and the process that holds saved carts. Behaviour is described in Notifications and consent.

Operator panel, customer panel, and sessions

Both cookie flags default to true so a deployment that forgets them still gets a cookie a browser will only send over TLS. Setting either to false logs a warning naming the risk — admin session cookie is not marked Secure; use this only for local development.
APP_ADMIN_COOKIE_SECURE=false and APP_CUSTOMER_COOKIE_SECURE=false are for a local plain-HTTP stack only. Without Secure, the session cookie can be captured from any unencrypted request to the same host.
The sign-in link is off by default because it is a second bearer credential travelling through a chat. Turn it on where the Telegram login widget cannot be used — typically because the domain cannot be bound in BotFather — and the bot’s /login command starts working. Telegram sign-in itself needs no variable of its own: it verifies the widget payload with APP_TELEGRAM_TOKEN. APP_TRUSTED_PROXIES is parsed once at startup and used by both the operator and the customer surfaces. A malformed entry is a startup error (trusted proxy entry must be an IP address or CIDR block); a bare IP becomes a /32 or /128 prefix. An X-Forwarded-For header is trivially forged, so it is honoured only when the immediate peer is one of the configured proxies, and with none configured the header is ignored entirely and the socket address is used — the correct default for a directly exposed deployment. The resolved address feeds rate limiting, the customer security event log, and the audit trail, so both directions of error matter: trust nothing and every request behind a proxy shares one address; trust too much and an attacker picks their own. For a Docker bridge behind a reverse proxy, 172.16.0.0/12 is the usual value. See Reverse proxy, Admin panel, and Customer web panel.

Operator notifications and the /ops surface

With APP_OPERATOR_CHAT_ID left at 0 the notifier logs operator notifications are disabled because no group is configured and enqueueing becomes a no-op, so notices are not even stored. The /ops command is registered unconditionally and simply answers nothing to a Telegram account that is not listed, so the command’s existence is never confirmed to a customer; it also only responds in a private chat. See Operator notifications.

Backups

The bot and worker images are built with the PostgreSQL client tools included and both mount the same backup-data volume at /var/lib/omniflow/backups, so the defaults work unchanged under compose. The same key is also read directly — before any configuration loading — by worker --decrypt-backup, which turns an encrypted dump back into a pg_restore input using nothing but the binary, the key, and the file.
Store APP_BACKUP_ENCRYPTION_KEY somewhere the database backup itself does not reach. A backup encrypted with a key that exists only inside that backup cannot be restored.
See Backup and restore.

Maintenance detection

Only the API acts on these. It is the process that already probes every dependency for readiness, so it is the process that runs the controller; the worker’s loader parses the same four values and uses none of them. Streaks exist so one timeout cannot close an installation, and a window an operator opened by hand is never cleared automatically. Automatic detection watches postgres and remnawave only — a Valkey outage appears in /readyz and never opens maintenance on its own. See Maintenance mode.

Retention and support attachments

Retention only removes disposable operational data. Financial, audit, consent, identity, and entitlement history is append-only for the life of the installation and is never deleted by a sweep. Only the worker runs retention, so an installation running just the API and the bot never cleans up. The outbox window currently deletes nothing. Omniflow writes outbox_events rows for order.paid, wallet.credited, and addon.purchased, but no process marks one as published, and the delete statement only matches published rows. Expect the outbox to grow and expect GET /v1/admin/outbox to report a rising pending count; see Jobs and queues.
APP_SUPPORT_ATTACHMENT_DIR is absent from .env.example, and compose.yaml mounts no volume for it on either the api or the worker service. A browser-uploaded ticket attachment therefore lives in the API container’s writable layer and is lost when the container is recreated, and the worker — pointed at its own empty copy of the same path — deletes the rows at retention while any surviving bytes stay where they are. If you accept web uploads, set the variable to the same path on both processes and mount one shared volume there.
Attachments uploaded through Telegram are unaffected: their bytes stay with Telegram and Omniflow stores only the reference. See Support desk.

Anonymous telemetry

Telemetry is on by default and only the API sends it — neither the bot nor the worker constructs a client. APP_TELEMETRY_ENABLED=false prevents all telemetry network requests, and on start the API logs either anonymous telemetry enabled with disable_with=APP_TELEMETRY_ENABLED=false or anonymous telemetry disabled.
The panel’s telemetry preview under Settings reads a stored settings document rather than the running configuration, so a default installation shows the preview as disabled while the API is in fact sending heartbeats. Treat APP_TELEMETRY_ENABLED as the answer to whether telemetry is on.
The exact payload is on Telemetry.

Operational endpoints

Three details differ from what the variable names suggest, and each has caught operators out:
  • The bot ignores APP_METRICS_ENABLED. It builds its metrics registry unconditionally and serves /metrics whenever APP_BOT_METRICS_ADDR is set.
  • APP_BOT_METRICS_ADDR is ignored when it equals APP_BOT_HTTP_ADDR. Serving metrics and readiness on the internet-facing webhook listener would publish installation internals on a host that must be reachable from anywhere, so the bot declines instead.
  • Clearing APP_WORKER_HTTP_ADDR does not disable the worker’s listener. An empty value is treated as unset and falls back to :8081. Keep the port off the public network at the proxy or firewall instead.
The bot has no probe endpoint out of the box, because APP_BOT_METRICS_ADDR is unset by default. Set it if you want /readyz on all three processes. Both bundled reverse-proxy examples return 404 for /metrics and /readyz on the public API host. See Observability.

Tracing

Omniflow instruments inbound HTTP, Telegram updates, fulfillment jobs, PostgreSQL queries, and outbound HTTP with OpenTelemetry spans, but no Omniflow process installs a tracer provider and no OTEL_* variable is read anywhere in the code. With no provider installed the global one is a no-op, so setting OTEL_EXPORTER_OTLP_ENDPOINT or any other standard SDK variable changes nothing today. There is no configuration that turns tracing on. Metrics and structured logs are the supported operational signals.

Web application

The Next.js app in apps/web reads its own variables. They are set on the web service in compose.yaml, not in .env. Browser-side requests are same-origin by default; a cross-origin deployment sets the client’s base URL explicitly rather than through an environment variable.

Settings the panel stores but does not apply

The operator panel writes an installation_settings document per section — branding, Remnawave, the bot, the operator group, maintenance, notifications, telemetry, backups, session policy. Those documents are read back only by the panel’s own settings screen and the telemetry preview. No running process reads a section document or its sealed secrets, so saving one changes no behaviour: the Remnawave URL and token, the bot token, the operator group, maintenance policy, telemetry enablement, and the backup schedule all come from the environment variables above, read once at startup. Treat .env as the source of truth and the settings screen as a record of intent until that changes. Installation settings documents the screen and this boundary.

Secrets

These values are credentials and should be handled as such: APP_DATABASE_URL (it carries the password), APP_REMNAWAVE_TOKEN, APP_OPERATOR_TOKEN, APP_TELEGRAM_TOKEN, APP_TELEGRAM_WEBHOOK_SECRET, APP_CRYPTOBOT_TOKEN, APP_YOOKASSA_SECRET, and the two AES keys APP_DATA_ENCRYPTION_KEY and APP_BACKUP_ENCRYPTION_KEY. Never commit .env or a production credential; the repository runs a secret scanner in CI for exactly this. Omniflow’s log redactor removes bot tokens, bearer and basic credentials, card-like digit runs, device identifiers, and any URL from anything that could reach a log line, an operator notification, or a diagnostics bundle, on the stated trade that an over-redacted log line is a minor inconvenience while a leaked subscription link or bot token is a security incident. That protects accidents, not configuration: a token pasted into the wrong variable is still a token an operator can read from the process environment. Use TLS for external PostgreSQL, Valkey, Remnawave, and payment-provider connections. The whole posture is described in Security.