Environment variables
Every variable the platform reads from .env, grouped by subsystem — which ones are required, which ones the code reads but the template never declares, and which ones nothing reads at all.
Configuration lives in one file: .env at the repository root, next to
package.json. .env.example is the template the installer copies when no
.env exists.
The backend loads it before any other module runs, probing four paths in order and stopping at the first that exists:
<cwd>/.env # the repo root — this is the one you edit
<backend>/../.env
<backend>/.env
<cwd>/../.envIf none is found it falls back to the ambient process environment, which is how a container deployment can supply everything without a file at all.
The installer sets chmod 600 .env. Keep it that way — the file holds your
database password, four session-signing secrets, every payment credential, and
the passphrase that unlocks custodial wallet keys.
Editing it safely
pnpm env-manager is a targeted line editor for this file. It replaces one line
at a time, so comments, section headers, ordering and quoting survive; a
round-trip through a .env parser strips all of that.
node scripts/env-manager.mjs get --json
node scripts/env-manager.mjs set APP_TWILIO_AUTH_TOKEN=abc123 --restartEvery write snapshots a timestamped .env.bak and renames a temp file into
place. With --restart it drains the backend, restarts, health-checks it, and
rolls back to the snapshot if the process does not come back healthy.
Secret-looking keys are redacted on read, so get reports set/unset rather than
values.
The tool refuses to edit ENCRYPTED_ENCRYPTION_KEY and
ENCRYPTION_KEY_PASSPHRASE at all. Changing either permanently bricks every
encrypted wallet on the install.
Two rules that decide whether an edit takes effect
Anything named NEXT_PUBLIC_* is inlined by Next.js at build time. Editing it
and restarting changes nothing in the browser.
NEXT_PUBLIC_SITE_URL is the worst case: every client API call falls back to it
(frontend/lib/api.ts), and its hostname is baked into images.remotePatterns
in next.config.js. Move the platform to a new domain without running
pnpm build:frontend and the browser keeps calling the old origin while
next/image rejects every image served from the new one.
Everything else is read when a process starts. pnpm restart picks it up —
pnpm stop && pnpm start, which parks the site on the maintenance server in
between.
Application
next/image will optimise. Changing it needs a frontend rebuild.Bicrypto in the PM2 config and most components, My App in the root layout's page titles, App in the PWA manifest — so set it explicitly rather than relying on any of them.true, EVERY new registration is given the Admin role — email/password and Google signup alike, on production builds too. Demo mode also blocks admin writes for anyone who is not Super Admin, and narrows scheduler refusal alerts to Super Admins so a refused cron job does not email every visitor. Leave it false on a real deployment.production on a live install. It is what makes session cookies Secure + SameSite=None, so a production build served over plain HTTP cannot log anyone in. It also drops localhost origins from the CORS allowlist.frontend PM2 app hardcodes PORT: 3000 in its own env block, and a PM2 env block beats the process environment, so editing this does not move the frontend.PORT is ignored — this is the only variable that moves it. The cron app deliberately sits on 4001; nothing should connect there.pnpm start:thread). Clamped to the CPU count. Read by backend/thread.ts and production.thread.config.js; no application code reads it.dark, light or system.frontend/next.config.js; has no effect on a production build.inline means one process both serves HTTP and runs the scheduler; off registers no jobs; only runs jobs and serves no traffic. production.config.js sets off on the backend app and only on the cron app, so the split is already whole. Set CRON_MODE="inline" in .env to collapse back to one process — production.config.js reads it and drops the cron app entirely.Database
backend/config.js treats an empty value as missing and logs a boot error, though it does not stop the process; the database backup and restore endpoints coerce it to an empty string instead. Set a real password.lazy (the default) only alters tables when the model fingerprint in backend/.sync-hash changed. none authenticates and touches nothing — the setting to reach for when you are diagnosing foreign-key churn. always forces a full ALTER sync, for a schema that drifted outside Sequelize. force DROPS and recreates every table and loses all data.It is not a repair mode. It drops every table and recreates it empty. If you are
trying to fix a schema that no longer matches the models, always is the escape
hatch.
Sessions and token secrets
All four are 128-hex-character values. The installer generates them with
crypto.randomBytes(64) on a fresh install. .env.example ships real-looking
sample values — replace them.
There is no fallback and no default. Any secret that is unset or shorter than 32
characters makes the route that needs it fail with a 500 at the moment it is
used, not at boot, so a bad APP_RESET_TOKEN_SECRET looks like "password reset
is broken" rather than "the platform will not start".
s, m, h, d parse; anything else throws a 400 on login. The template ships 30m; the code default when the variable is absent is 15m."true" only for a load balancer on a different host — it then believes a forwarding header from any peer, which is dangerous while the API port is reachable directly. "false" disables the header entirely (diagnostics only) and collapses every visitor into one rate-limit bucket.10.0.0.0/8. The safe way to trust a proxy on another host. Also unlocks the single-value CDN headers (CF-Connecting-IP, True-Client-IP, X-Real-IP), which are ignored by default because Apache and nginx forward them straight through from the client.Rate limiting
RATE_LIMIT_EXPIRY is still honoured for installs that already set it, but this name wins.RATE_LIMIT.Redis
Redis is a hard boot dependency, not a cache. Sessions, CSRF tokens, rate-limit
counters, distributed locks, the BullMQ scheduler and cross-process settings
invalidation all live in it. The backend exits with code 78 (EX_CONFIG) when
it is unreachable, printing the host and port it tried. Every PM2 config lists
78 in stop_exit_codes, so PM2 stops the app instead of crash-looping it.
sudo apt-get install -y redis-server && sudo systemctl enable --now redis-server
redis-cli -h 127.0.0.1 -p 6379 ping # expects: PONG.env.example.nodemailer-service, nodemailer-smtp, nodemailer-sendgrid or local.nodemailer-service: the well-known provider, e.g. gmail or outlook.tls, 465 with ssl.tls for STARTTLS on 587, ssl for implicit TLS on 465. Mismatching this with the port produces a connection that hangs rather than a clear error.APP_NODEMAILER_SMTP_USERNAME is set.nodemailer-sendgrid transport.sendmail binary, for the local transport. Find it with which sendmail..env.example ships APP_EMAILER="nodemailer-smtp" with port 587 and
tls. If you delete those lines rather than filling them in, the code defaults
take over — nodemailer-service, smtp.gmail.com, port 465, ssl — and mail
silently goes nowhere. Set every mail variable explicitly.
SMS
Twilio delivers every SMS the platform sends: login and 2FA codes, phone
verification, withdrawal and password-change codes, and notification messages.
The provider refuses to initialise unless the account SID starts with AC and
either a phone number or a messaging service SID is present.
AC.msg91 moves codes to MSG91 while Twilio still sends everything else. MSG91 cannot carry free-text notifications — its send API requires a registered template, and DLT caps each template variable at about 30 characters.tokenAuth from an OTP Widget snippet: that is a public browser token, MSG91 rejects it, and sends still report success. Verify at Admin → System → SMS Providers.##OTP## as the placeholder. OTP templates are approved instantly.Push notifications
pnpm vapid:generate.mailto: URI.Exchange providers
Which exchange is live is a database row set from Admin → Finance → Exchange Providers, not an environment variable. The backend then builds the credential names from the provider alias at runtime:
APP_${PROVIDER}_API_KEY
APP_${PROVIDER}_API_SECRET
APP_${PROVIDER}_API_PASSPHRASESo a grep for APP_BINANCE_API_KEY in the source finds nothing even though the
variable is load-bearing. Add the trio for whichever provider you activate.
bin, kuc, kra, okx, xt. Read only by the frontend chart and market-data code; zero backend readers, so it selects the chart symbols, not the trading connection.Fiat exchange rates
Every configured provider is queried each run and the results are merged, so coverage is the union — a currency one source is missing is still priced by another. The keyless providers alone cover roughly 159 of 160 currencies.
openexchangerates, exchangerate-api, open-er-api, currency-api, frankfurter. Providers whose key is absent are skipped automatically. Leave unset to use all of them.consensus takes the largest cluster of agreeing sources, which guards against a stale primary — OpenExchangeRates was observed serving SSP at 130 while three other sources agreed on ~4900. priority always takes the earliest-listed provider that has it. Either way, disagreement above 2% is logged with every source's value.APP_FIAT_RATES_PROVIDERS.CODE=units-per-USD overrides, for codes reused after a redenomination where sources disagree about which unit the code names. CODE=retired drops the currency. Read by the rate merger but not declared in .env.example.Deposit gateways
Each gateway's readiness is computed from these variables, not from the database
row — the credential names in backend/src/utils/deposit-gateway/registry.ts
are read straight out of process.env, and Admin → Finance → Deposit → Gateways
reports a gateway as unconfigured until they are present. All are optional:
leave blank for any gateway you do not enable.
Stripe, PayPal, Paystack
true for the Paystack test environment.TransFi (fiat on/off-ramp)
Sandbox and production credentials are not interchangeable: sandbox credentials
return UNAUTHORIZED_CUSTOMER against api.transfi.com, and vice versa.
NODE_ENV, for the reason above. https://sandbox-api.transfi.com or https://api.transfi.com. TransFi's own auth docs print api-sandbox.transfi.com; that host does not resolve, so do not "fix" this value to match them.other. Minimum 10 characters.raw (recommended) or python. Unset means try raw, then fall back and warn.false forces production when the base URL is unset.The other twelve gateways
test or live.NODE_ENV, not from a flag of its own.true for the PayFast sandbox.true for the Paysafe test environment.WEBSTAGING in test.true for the Paytm staging environment.true for the PayU test environment.FRONTEND_URL for a successful PayU payment.FRONTEND_URL for a failed PayU payment.FRONTEND_URL when the customer cancels.Both gateways build their return URLs as ${FRONTEND_URL}${path}. With
FRONTEND_URL unset the customer is sent to the literal string
undefined/finance/deposit?status=success&ref=… and never returns to the site.
Add FRONTEND_URL to .env before enabling either gateway.
Forex A-book execution
Hedge-execution venue credentials for the forex trading extension's A-book layer. These are not the market-data provider keys. All optional — leave blank for pure B-book operation.
AI services
Blockchain and the ecosystem extension
.env.example declares no RPC endpoint for any chain. A comment block
describes the naming convention and stops there, so every endpoint the ecosystem
extension needs has to be added by hand. Two things are exceptions. The explorer
and transaction-provider keys further down are in the template, under
Explorer / transaction-history providers. And custom EVM chains live in the
ecosystem_custom_chain table, are managed from Admin → Ecosystem → Custom EVM
Chains, and are written into process.env at boot from the database.
The naming convention is mechanical:
ETH_NETWORK="mainnet"
ETH_MAINNET_RPC="https://..."
ETH_MAINNET_RPC_WSS="wss://..."
ETH_EXPLORER_API_KEY="..."<SYMBOL>_NETWORK selects the network (default mainnet), and the code then
reads <SYMBOL>_<NETWORK>_RPC and <SYMBOL>_<NETWORK>_RPC_WSS for that
network. <SYMBOL>_EXPLORER_API_KEY is the per-chain Etherscan key, tried
before ETHERSCAN_API_KEY rather than instead of it — the two lists are
concatenated, so a stale per-chain key no longer shadows a working global one.
The EVM symbols in use are ETH, BSC, POLYGON, FTM, OPTIMISM,
ARBITRUM, CELO, BASE, RSK, plus MO.
UTXO chains take node connection details instead: <SYMBOL>_NODE_HOST (default
127.0.0.1), _NODE_PORT, _NODE_USER, _NODE_PASSWORD, and
<SYMBOL>_MEMPOOL_API_URL. Non-EVM chains use their own families —
TRON_NETWORK and TRON_API_KEY, SOL_NETWORK and SOLANA_RPC_URL,
TON_NETWORK with TON_MAINNET_RPC and TON_MAINNET_RPC_API_KEY,
XMR_DAEMON_RPC_URL (default http://127.0.0.1:18081/json_rpc) and
XMR_WALLET_RPC_URL (default port 18083).
<SYMBOL>_EXPLORER_API_KEY of its own. Without it, transaction history, token metadata and contract verification lookups fall through to the keyless providers described below — which cover most chains, but not BSC, Fantom, Cronos, HECO or Polygon Amoy.true to run the ecosystem deposit monitors. Off by default.ARBIRUM_MAINNET_RPC and ARBIRUM_MAINNET_RPC_WSS — missing the second "T" —
are still read as fallbacks by the admin balance endpoint and the system health
check. The real provider path only reads the correctly spelled
ARBITRUM_MAINNET_RPC.
Set only the typo key and you get the worst outcome available: health reports
Arbitrum as Up while deposits and withdrawals are broken. Neither spelling is in
.env.example. Always set ARBITRUM_MAINNET_RPC.
Explorer and transaction-history providers
Seven providers serve EVM transaction history and native-deposit detection,
tried in a per-chain order with automatic failover. Two of them —
Blockscout and Routescan — need no credential, and each is appended to the end
of the order of every chain it can serve, so most chains work with none of these
keys set. Five chains are the exception: BSC (56 and 97), Fantom (250 and
4002), Cronos (25), HECO (128 and 256) and Polygon Amoy (80002) have neither a
hosted Blockscout instance nor Routescan coverage, so none of them gets a
keyless provider appended to its order. BSC is the one where a keyed provider
is the normal answer for a
production install: NODEREAL_API_KEY is free for BSC mainnet, and on BSC
testnet only MORALIS_API_KEY / COVALENT_API_KEY index it.
Every provider key, ETHERSCAN_API_KEY above included, may hold several
comma-separated keys, rotated through on auth, plan and rate-limit failures, and
every one has a chain-scoped form —
BSC_NODEREAL_API_KEY, POLYGON_COVALENT_API_KEY — that is tried first with
the global value behind it as a spare. The full per-chain picture is in the
Ecosystem environment reference.
true to stop the keyless Blockscout/Routescan tail being appended to the order you configured.Master wallet encryption
Two variables unlock every custodial private key on the install. Neither is in
.env.example. Generate them once, before creating any master wallet:
node scripts/kms/generate.mjsIt creates a 32-byte key, asks for a passphrase of at least 12 characters, and
writes the AES-256-GCM result back to .env as four colon-separated hex parts
(IV, auth tag, ciphertext, salt).
Change or lose either value and every encrypted wallet on the install becomes
permanently unreadable. There is no recovery path and no support workaround.
pnpm env-manager refuses to edit them for exactly this reason. Back up .env
somewhere the database backup does not live.
ScyllaDB
Ecosystem and futures order books, candles and trade tape live in ScyllaDB, not
MySQL. The installer does not install it and .env.example declares none of
these — the defaults below are what the code assumes when the variables are
absent. Neither the built-in database backup nor mysqldump covers this data.
false to disable Scylla entirely. Ecosystem trading then answers 503 rather than failing at boot, which is the right shape for an install that does not use the ecosystem extension.Licensing and product identity
.lic files under lic/. Undeclared in the template, and two code paths disagree about what happens when it is unset — one falls back to a build-time constant, the other to the literal string default-secret. Set it explicitly or leave it entirely unset; do not set it on one install and not another.updates.mashdiv.com must not be firewalled; there is a 72-hour grace period when it is unreachable.Two-factor policy
The five withdrawTwoFactor* platform settings in Admin → System → Settings are
the live controls. These three are the legacy fallbacks the login paths still
read, and they are undeclared in .env.example.
Other operational variables
None of these are in .env.example either, but several change behaviour you can
observe.
true, 1, yes or on to suppress all outbound mail. Useful on a staging clone of production data.APP_NODEMAILER_SMTP_SENDER.TRUST_PROXY wins when both are present.debug also turns on verbose API request logging.0 to turn the report off. Use it when an operation is reported as slow and you need to know which step to look at: it names the wallet hold, the book read or the matching handoff rather than leaving you with one total.0 sends every change immediately, which is the older behaviour and is measurably more expensive on a market that has bots quoting it. The market data socket also re-sends a full book every two seconds regardless.backups/nft under the project root.Variables the code reads that the template never declares
Roughly two hundred variable names are read somewhere in backend/src and
appear nowhere in .env.example. Most are tuning knobs with sane defaults.
These are the ones that change whether something works:
ENCRYPTED_ENCRYPTION_KEY, ENCRYPTION_KEY_PASSPHRASE — every custodial
private key on the install. Unrecoverable if lost.
FRONTEND_URL — PayU and Authorize.Net build customer return URLs from it.
Unset produces undefined/finance/deposit?....
ARBITRUM_MAINNET_RPC — the correctly spelled key. The typo variant is read as
a fallback by health checks only.
Every blockchain RPC endpoint: roughly 60 names across the
<SYMBOL>_NETWORK / <SYMBOL>_<NET>_RPC families plus the UTXO node and
non-EVM families described above.
All eight SCYLLA_* variables. Ecosystem and futures trading do not work
without a reachable cluster, and no backup in the product covers its data.
REDIS_DB — the logical database index. Declared readers exist; the template
stops at host, port and password.
TRUST_PROXY and HB_TRUST_PROXY — two independent proxy-trust flags, both
required behind a reverse proxy.
NEXT_PUBLIC_2FA_EMAIL_STATUS, NEXT_PUBLIC_2FA_SMS_STATUS,
NEXT_PUBLIC_2FA_APP_STATUS — read by every login path and the withdrawal 2FA
resolver.
SUMSUB_API_KEY, SUMSUB_API_SECRET — the Sumsub KYC integration.
LICENSE_SECRET, MAIN_PRODUCT_ID, HEARTBEAT_INTERVAL.
APP_NODEMAILER_SMTP_USERNAME, APP_EMAIL_FROM, APP_EMAIL_FROM_NAME,
APP_NODEMAILER_ALLOW_INSECURE_TLS, the three
APP_NODEMAILER_DKIM_* variables, and MAIL_DISABLED.
A third set of names for values you have already configured, each read by
exactly one file. EMAIL_PROVIDER, EMAIL_FROM, SENDGRID_API_KEY,
SMTP_HOST and SMTP_PORT are read only by the admin notification-settings
screen; SITE_NAME and SITE_DESCRIPTION only by the API docs generator;
APP_DEFAULT_LOCALE only by the payment gateway extension.
Setting them does not configure mail or the site name. Use the APP_* and
NEXT_PUBLIC_* names documented above.
RATE_LIMIT_EXPIRY is honoured as a fallback for RATE_LIMIT_EXPIRE. For years
the code read one spelling and the template shipped the other, so the window was
permanently 60 seconds and editing the documented variable changed nothing. Both
work now; prefer RATE_LIMIT_EXPIRE.
Variables in the template that nothing reads
Setting any of these has no effect anywhere in the product. They are listed so you stop trying.
| Variable | Note |
|---|---|
OPENAI_API_KEY |
The AI verification path supports Gemini and DeepSeek only. No OpenAI SDK is imported anywhere in the backend. |
APP_CLIENT_PLATFORM |
Twenty lines of instructions in the template for a value with no reader. |
APP_SUPPORT_PHONE_NUMBER |
No reader. |
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID |
No reader. The only gtag reference in the repo is a TypeScript declaration. |
NEXT_PUBLIC_FACEBOOK_PIXEL_ID |
No reader. |
NEXT_PUBLIC_FRONTEND |
No reader. |
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY |
Commented out. Consumed by Next.js internals if uncommented, never by application code. |
The matching googleAnalyticsStatus and facebookPixelStatus switches in
Admin → System → Settings → Integrations are equally inert — turning them on
injects nothing.