Skip to main content
This page takes a clone of the repository to a running installation with an operator account you can sign in to. It does not sell anything yet — that is Your first sale, which starts where this page ends. Everything below is the local Compose stack that ships in the repository. It publishes ports on localhost, uses the credentials in compose.yaml, and terminates no TLS. For a server that faces the internet, read Deployment and Reverse proxy before you expose anything.

Before you start

What each service is

compose.yaml defines seven services. Only api and web publish a host port; PostgreSQL and Valkey stay on the internal Compose network.
The migrate service has the development DSN postgres://omniflow:omniflow@postgres:5432/omniflow?sslmode=disable hard-coded in its command and does not read .env. If you change the database credentials, change them in compose.yaml as well, or migrations will be applied somewhere other than where the application looks.

Configure the installation

All project-owned configuration is environment variables with an APP_ prefix, read once at startup. There is no config file and no live reload: changing a value means restarting the process that reads it.
1

Copy the example file

The example is a complete, commented file. Every value in it is either a working default or an empty string you are expected to fill in.
2

Generate the two secrets

APP_DATA_ENCRYPTION_KEY must decode to exactly 32 bytes or startup fails with APP_DATA_ENCRYPTION_KEY must be base64-encoded 32 bytes. It seals contact values, TOTP secrets, OIDC client secrets, provider credentials, and the short-lived sign-in flow cookie — losing it means losing access to all of them.APP_OPERATOR_TOKEN is the bearer credential for the entire /v1/admin prefix, compared in constant time. Any random high-entropy string will do.
3

Fill in the values the processes require

The refusals are deliberate and happen at startup rather than at the first request:Other values are validated the same way: APP_DEFAULT_CURRENCY must match ^[A-Z]{3}$ after uppercasing, APP_TELEGRAM_WEBHOOK_URL must be HTTPS, and APP_TELEMETRY_ENDPOINT must be an HTTPS URL while telemetry is enabled.
4

Allow plain-HTTP cookies for a local stack

Both default to true, which is correct for anything with TLS in front of it. The API logs admin session cookie is not marked Secure; use this only for local development when you turn it off, and that warning is accurate: without Secure, the session cookie can be captured from any unencrypted request to the same host. Set both back to true before the installation leaves your machine.

Bring the stack up

migrate runs first and exits; api, bot, and worker start once it has completed successfully and Valkey reports healthy. Then start the web application, which serves both the customer account and the operator workspace:
Afterwards:
  • API — http://localhost:8080
  • Customer account — http://localhost:3000
  • Operator workspace — http://localhost:3000/admin
make dev is the first command without migrate in the argument list, make up is everything including the web profile, and make down stops it all. Run make help for the full index of targets.

Create the first owner

A fresh installation has no operator account, so the sign-in page would be a dead end. On start, while no operator exists, the API issues a one-time setup token and writes it to its own log at warning level. It goes to the log rather than to an HTTP response because at that moment nothing can authenticate, and an endpoint that handed the token out would hand it to anyone who asked.
1

Read the token from the API log

The token is valid for 30 minutes and can be redeemed once. Restarting the API while no owner exists issues a new one; once an owner exists, a restart prints nothing and POST /v1/panel/bootstrap answers 409 bootstrap_closed permanently.
2

Redeem it at /admin/setup

Open http://localhost:3000/admin/setup. The form asks for the setup token, a display name of 1–80 characters, an email address, and a password of 12–256 characters. There are no composition rules; length is the only requirement, and the browser form mirrors exactly what the API enforces.Opening /admin/login on an installation that still needs its first owner redirects here automatically, and this page redirects back to the sign-in screen the moment an account exists, so a stale tab cannot sit on a form that can no longer be submitted.A refusal is specific: 422 weak_password for a password that is too short, 409 email_in_use for an address already registered, 409 bootstrap_closed once bootstrap has completed.
3

Sign in

You are redirected to /admin/login. Enter the address and password. The account has no second factor yet, so you land on the dashboard at /admin.Sign-in answers identically for a wrong password, an unknown address, and a disabled account — all three are 401 invalid_credentials — so the endpoint cannot be used to discover which addresses have operator accounts. It is rate limited to 10 attempts per minute per address and 30 per minute per client IP, and if Valkey is unreachable the check fails closed.

Enable two-factor authentication

The first owner holds every permission in the installation, including the only role that can create other operators. Give the account a second factor before you do anything else with it.
1

Start enrolment

Open /admin/security and press Enable. The response carries a TOTP secret and an otpauth:// URI. The secret is shown as text as well as in the QR code, because an authenticator running on the same device cannot scan the screen it is on.The label your authenticator shows comes from APP_ADMIN_TOTP_ISSUER, which defaults to Omniflow.
2

Confirm the first code

Type the six-digit code and submit. Until a code is verified the secret is stored unconfirmed and satisfies no sign-in challenge, so a half-finished enrolment can never lock you out. A wrong code answers 422 invalid_code; confirming without starting answers 409 totp_not_started.
3

Store the recovery codes

Confirmation returns ten single-use recovery codes. They are displayed once and never again — regenerating them replaces the whole set and invalidates every unused code. The sign-in challenge field accepts 6–24 characters and is a text input rather than a numeric one, because a recovery code contains letters.
Disabling the second factor requires the account password, because removing a factor is exactly what an attacker holding a hijacked session would try. Changing the password ends every other session, and Sign out everywhere else deliberately spares the session you are using to secure the account. Sessions are governed by three numbers: a 30-minute idle timeout that slides, a 12-hour absolute timeout that never moves, and token rotation every 15 minutes.

Prove that it works

Liveness and readiness answer different questions and are separate endpoints on purpose: a process that is running but cannot reach PostgreSQL should be removed from a load balancer, not restarted. /healthz is the same handler as /livez, kept for deployments that already probe it.
A healthy API answers 200:
An unhealthy dependency turns the response into 503 with "status": "degraded" and the failing check carrying "error": "timeout" or "error": "unavailable" plus a consecutiveFailures count. Those two words are the only failure values there are: a pgx or HTTP error routinely contains a DSN, a host name, or a credential, so the probe reports a classification instead. The remnawave probe is an authenticated call, which means a revoked API token shows up here as an unhealthy dependency rather than as a mysterious provisioning failure later. Which probes each process registers differs, and it matters:
An API started without APP_DATABASE_URL reports itself ready while serving almost nothing. If /readyz is green but /v1/catalog/plans is not there, check that the database URL reached the container.
Signed in as an operator, the same information is on /admin under System → Health (GET /v1/panel/overview/health, permission system.read), together with payment-provider and digital-goods-provider health that the HTTP probe does not cover.

What does not work on a fresh clone

These are current facts about main, not configuration mistakes. Read them before you conclude that your installation is broken.
  • The durable job queue has no schema. Omniflow enqueues background work with River, but no committed migration creates River’s own tables and nothing in the stack runs River’s migrator. On a database migrated only by docker compose up migrate, worker therefore exits at startup logging start River, and any settlement — which inserts a fulfillment job in the same transaction as the money — rolls back. A purchase cannot complete until that migration exists. See Your first sale for exactly where a test purchase stops, and Jobs and queues for what the queue is meant to do.
  • The transactional outbox is written and never published. Three topics are inserted, nothing ever sets published_at, and no dispatcher exists. The outbox counters on the dashboard and at GET /v1/admin/outbox therefore grow permanently, APP_RETENTION_OUTBOX_DAYS deletes nothing, and the “outbox lag” attention item on /admin is expected rather than diagnostic.
  • There is no tracer provider. The code opens spans across HTTP, Telegram updates, jobs, and SQL, but no exporter is installed and no OTEL_* variable is read anywhere in the repository. Setting the standard OpenTelemetry variables changes nothing; the global provider stays a no-op. Metrics and logs are what an installation actually emits — see Observability.
  • Every binary reports "version":"dev". Nothing stamps a version at build time, including the released images, so /livez, the diagnostics bundle, and the telemetry heartbeat all say dev.
  • The panel’s settings sections do not configure the runtime. Saving a section stores a document; the Remnawave connection, the bot, the operator group, maintenance policy, telemetry, backups, and the payment adapters all come from environment variables read at startup. See Installation settings.
  • Nothing restarts a crashed process. No core Compose service declares a restart: policy, so a process that exits stays down until somebody notices. Add one for anything long-lived.
  • A browser-uploaded support attachment has no volume. APP_SUPPORT_ATTACHMENT_DIR defaults to /var/lib/omniflow/support-attachments and is read by both api and worker, but compose.yaml mounts a volume there for neither. Files live in the container’s writable layer until it is rebuilt, and the worker cannot reclaim files it cannot see.

Next steps

Your first sale

Create a plan, price a version, configure a provider, connect the bot, and run a test purchase.

Configuration reference

Every variable, its default, which process reads it, and what it changes.

Deployment

Volumes, image tags, migrations, upgrades, and the supported topology.

Reverse proxy

TLS, body limits, and keeping /metrics and /readyz off the public host.

Backup and restore

Turn on encrypted backups before there is anything worth losing.

Anonymous telemetry

The exact payload, and the one variable that stops every telemetry request.