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.

16 min readUpdated 6 September 2026env, configuration, secrets

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>/../.env

If 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 --restart

Every 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_PUBLIC_SITE_URLtype: urldefault: http://localhostscope: required, rebuild
The canonical public origin. In production it is the entire CORS allowlist — the backend derives http/https and www/non-www variants from this value and nothing else, so an unset value produces an empty allowlist and every browser request fails. Also the base for client API calls and the only hostname next/image will optimise. Changing it needs a frontend rebuild.
NEXT_PUBLIC_SITE_NAMEtype: stringdefault: Bicryptoscope: rebuild
Shown in the header, page titles and outbound email. Unset, the fallback depends on the reader — 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.
NEXT_PUBLIC_SITE_DESCRIPTIONtype: stringscope: rebuild
Meta description for the public pages.
NEXT_PUBLIC_DEMO_STATUStype: booleandefault: false
Demo sites only. When 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.
NODE_ENVtype: enumdefault: developmentscope: required
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.
NEXT_PUBLIC_FRONTEND_PORTtype: numberdefault: 3000
Declared for reference only. The 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.
NEXT_PUBLIC_BACKEND_PORTtype: numberdefault: 4000
The port the backend binds. PORT is ignored — this is the only variable that moves it. The cron app deliberately sits on 4001; nothing should connect there.
NEXT_PUBLIC_BACKEND_THREADStype: numberdefault: 2
Worker count for the threaded entry point only (pnpm start:thread). Clamped to the CPU count. Read by backend/thread.ts and production.thread.config.js; no application code reads it.
NEXT_PUBLIC_DEFAULT_LANGUAGEtype: stringdefault: enscope: rebuild
Locale used when the visitor has expressed no preference.
NEXT_PUBLIC_LANGUAGEStype: stringscope: rebuild
Comma-separated locale codes offered in the language switcher.
NEXT_PUBLIC_DEFAULT_THEMEtype: enumdefault: darkscope: rebuild
dark, light or system.
NEXT_PUBLIC_GOOGLE_CLIENT_IDtype: stringscope: rebuild
Google OAuth client ID. The backend verifies Google ID tokens against it on both login and registration, so a mismatch between this and the value the button was built with rejects every Google sign-in.
NEXT_PUBLIC_ALLOWED_DEV_IPStype: string
Development only. Extra IPs allowed to reach the dev server from other devices on the LAN, comma-separated. Read by frontend/next.config.js; has no effect on a production build.
CRON_MODEtype: enumdefault: inline
Not in the template on purpose. Unset or 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.
ECO_TRADING_ENABLEDtype: booleandefault: false
Opt-in for the dedicated trading process. Set to 1, true, yes or on (trimmed, case-insensitive) and production.config.js adds a fourth PM2 app, trading, after backend: the same entry point with CRON_MODE=off and ECO_PROCESS_ROLE=trading on ECO_TRADING_PORT, which hosts the Ecosystem matching engine, the AI market maker and the trading bots and serves the order, market, ticker and Hummingbot routes. The web app is marked ECO_PROCESS_ROLE=web and never holds the engine again. The trading app is given DB_POOL_MAX=40, WALLET_TX_CONCURRENCY=16 and SCYLLA_LOCAL_CONNECTIONS=8 as defaults that a value you set in .env still overrides. production.backend.config.js turns its single inline app into backend (web), trading and cron (only, 4001) for the same reason. The value is refused with an error at pm2 start in two layouts: combined with CRON_MODE=inline (a trading process must not also schedule, and an inline web process would race it for the engine), and in production.thread.config.js (worker threads run the matcher unarbitrated). Unset, all three configs produce exactly the app lists they produced before. The reverse proxy must move four path prefixes to the trading port or the trading routes reach a process that no longer hosts the engine; see the nginx and Apache sections and Two backend processes.
ECO_TRADING_PORTtype: numberdefault: 4010
Port of the trading process. Read only for the trading role; production.config.js and production.backend.config.js pin the trading app's PORT and NEXT_PUBLIC_BACKEND_PORT to it, because the backend binds NEXT_PUBLIC_BACKEND_PORT for every role and the two must agree with the port the proxy names. Both configs refuse a value equal to the web backend's port or to 4001, the cron app's. Like 4000 and 4001 it is loopback traffic only: firewall it, and never hand it to a browser.
ECO_PROCESS_ROLEtype: enumdefault: unset
NOT FOR .env. The engine-hosting axis of a backend process, set per app by the PM2 configs when ECO_TRADING_ENABLED is on: trading for the dedicated trading process (the only process that may hold the ecosystem-matching lease once the split is on), web for the web tier beside it (never a lease candidate). Unset is today's layout, where CRON_MODE alone decides and whoever runs CRON_MODE=off or inline hosts the engine. Any set value requires CRON_MODE=off; a process that finds the variable with CRON_MODE unset, inline or only, or with a value other than trading or web, refuses to boot with one line on stderr (Backend refused to start: ECO_PROCESS_ROLE=... requires CRON_MODE=off ...) and exit code 78, which PM2 treats as a stop rather than a restart loop. The refusal is deliberate: the value only has meaning inside the split layout, so a scheduling process finding it means it leaked in from .env or the shell, and a misspelt trading that booted as a web process would leave no process hosting the matcher. The engine health route reports the role as role.

Database

DB_NAMEtype: stringdefault: v4scope: required
Schema name. The installer prompts for it and writes it here.
DB_USERtype: stringdefault: rootscope: required
MySQL user.
DB_PASSWORDtype: secretscope: required
MySQL password. 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.
DB_HOSTtype: stringdefault: localhostscope: required
Database host.
DB_PORTtype: numberdefault: 3306
Database port. Read by the ORM connection and by the admin database backup and restore endpoints; the two endpoints fall back to 3306 when it is unset.
DB_BACKUP_RETAINtype: numberdefault: 10
Not in the template. How many dumps the admin backup screen keeps in backup/: every run of POST /api/admin/system/database/backup prunes the directory back to this many, oldest first, and only files matching the generated YYYY_MM_DD_HH_mm_ss.sql name shape are considered. 0, an empty value and anything non-numeric fall back to 10, so pruning cannot be switched off. A negative value is not rejected and prunes every dump in the directory, so do not set one. There is no delete endpoint, so nothing prunes between backup runs.
DB_SYNCtype: enumdefault: lazy
Schema sync mode. 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".

APP_ACCESS_TOKEN_SECRETtype: secretscope: required
Signs the short-lived access token on every session.
APP_REFRESH_TOKEN_SECRETtype: secretscope: required
Signs refresh tokens. Rotating it logs everyone out.
APP_RESET_TOKEN_SECRETtype: secretscope: required
Signs password-reset tokens.
APP_VERIFY_TOKEN_SECRETtype: secretscope: required
Signs email-verification tokens.
JWT_EXPIRYtype: durationdefault: 15m
Access-token lifetime. Only the suffixes 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.
JWT_REFRESH_EXPIRYtype: durationdefault: 14d
Refresh-token lifetime, and therefore how long an idle session survives.
JWT_RESET_EXPIRYtype: durationdefault: 1h
How long a password-reset link stays valid.
TRUST_PROXYtype: booleandefault: unset
LEAVE UNSET for a proxy on this machine. The backend honours a forwarding header whenever the connection came from loopback, so nginx and Apache work with no configuration. Set "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.
TRUST_PROXY_CIDRStype: stringdefault: (empty)
Networks other than loopback whose requests may carry a forwarding header — comma-separated addresses or CIDRs, e.g. 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_LIMITtype: numberdefault: 100
Per-IP cap on MUTATING requests (POST, PUT, PATCH, DELETE) per window. GETs are not counted.
RATE_LIMIT_EXPIREtype: numberdefault: 60
Window length in seconds. The legacy spelling RATE_LIMIT_EXPIRY is still honoured for installs that already set it, but this name wins.
HB_RATE_LIMITtype: numberdefault: 1200
Per-IP cap for HMAC-signed Hummingbot bot traffic, applied only when a full signature header set is present. Must be at least the addon's Trade budget (600/min) or that budget cannot be delivered.
HB_AUTH_FAIL_LIMITtype: numberdefault: 30
Failed HMAC verifications one IP may accumulate per minute before the bot-sized allowance is withdrawn and it drops back to 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
REDIS_HOSTtype: stringdefault: 127.0.0.1scope: required
Redis host.
REDIS_PORTtype: numberdefault: 6379scope: required
Redis port.
REDIS_PASSWORDtype: secret
Leave empty for an unauthenticated local Redis.
REDIS_DBtype: numberdefault: 0
Logical database index. Read by the connection code and the queue workers but not declared in .env.example.

Mail

APP_EMAILERtype: enumdefault: nodemailer-service
Which transport sends mail: nodemailer-service, nodemailer-smtp, nodemailer-sendgrid or local.
NEXT_PUBLIC_APP_EMAILtype: email
The address shown to recipients.
APP_EMAIL_SENDER_NAMEtype: string
Display name on outbound mail.
APP_NODEMAILER_SERVICEtype: string
For nodemailer-service: the well-known provider, e.g. gmail or outlook.
APP_NODEMAILER_SERVICE_SENDERtype: email
Mailbox address for the service transport.
APP_NODEMAILER_SERVICE_PASSWORDtype: secret
App password, not the account password. Gmail and Outlook both reject the real one.
APP_NODEMAILER_SMTP_HOSTtype: stringdefault: smtp.gmail.com
SMTP server hostname.
APP_NODEMAILER_SMTP_PORTtype: numberdefault: 465
SMTP port. 587 pairs with tls, 465 with ssl.
APP_NODEMAILER_SMTP_ENCRYPTIONtype: enumdefault: 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_SENDERtype: email
From address for the SMTP transport, and the login user unless APP_NODEMAILER_SMTP_USERNAME is set.
APP_NODEMAILER_SMTP_PASSWORDtype: secret
SMTP password.
APP_SENDGRID_API_KEYtype: secret
SendGrid API key, for the nodemailer-sendgrid transport.
APP_SENDGRID_SENDERtype: email
Verified SendGrid sender address.
APP_SENDMAIL_PATHtype: pathdefault: /usr/sbin/sendmail
Path to the local 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.

APP_TWILIO_ACCOUNT_SIDtype: string
Twilio account SID. Must start with AC.
APP_TWILIO_AUTH_TOKENtype: secret
Twilio auth token.
APP_TWILIO_PHONE_NUMBERtype: string
Sending number in E.164 form. Either this or a messaging service SID is required.
APP_TWILIO_MESSAGING_SERVICE_SIDtype: string
Messaging service SID, as an alternative to a single sending number.
SMS_OTP_PROVIDERtype: enum
Routes only one-time codes to a different provider. Empty means Twilio sends codes too; 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.
MSG91_AUTH_KEYtype: secret
MSG91 API key, from Settings → API Keys. NOT the 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.
MSG91_SENDER_IDtype: string
Optional sender ID. Without one MSG91 uses its shared sender; your own is only needed past roughly 2,000 messages a month in a country, or for branding.
MSG91_OTP_TEMPLATE_IDtype: string
Template ID from OTP → Add template, using ##OTP## as the placeholder. OTP templates are approved instantly.
MSG91_DLT_TE_IDtype: string
India only. DLT template entity ID, if your MSG91 account requires it.

Push notifications

FCM_PROJECT_IDtype: string
Firebase project ID for native mobile push. A non-empty value switches the FCM channel on, so a placeholder here half-enables it and fails at boot. Leave blank to disable.
FCM_PRIVATE_KEYtype: secret
Firebase service-account private key.
FCM_CLIENT_EMAILtype: email
Firebase service-account client email.
FCM_SERVICE_ACCOUNT_PATHtype: path
Path to a service-account JSON file, as an alternative to the three fields above.
VAPID_PUBLIC_KEYtype: string
VAPID public key for browser push. Works without Firebase, in Chrome, Firefox, Edge and Safari. Generate a pair with pnpm vapid:generate.
VAPID_PRIVATE_KEYtype: secret
VAPID private key.
VAPID_SUBJECTtype: stringdefault: mailto:admin@example.com
Contact address browsers show for push, as a 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_PASSPHRASE

So 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.

NEXT_PUBLIC_EXCHANGEtype: stringdefault: binscope: rebuild
First three letters of the exchange alias — 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.
APP_KUCOIN_API_KEYtype: secret
KuCoin API key.
APP_KUCOIN_API_SECRETtype: secret
KuCoin API secret.
APP_KUCOIN_API_PASSPHRASEtype: secret
KuCoin API passphrase. KuCoin is one of the providers that needs all three.
APP_BINANCE_API_KEYtype: secret
Binance API key.
APP_BINANCE_API_SECRETtype: secret
Binance API secret.
APP_XT_API_KEYtype: secret
XT API key.
APP_XT_API_SECRETtype: secret
XT API secret.

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.

APP_FIAT_RATES_PROVIDERStype: string
Comma-separated provider IDs in priority order: openexchangerates, exchangerate-api, open-er-api, currency-api, frankfurter. Providers whose key is absent are skipped automatically. Leave unset to use all of them.
APP_FIAT_RATES_MERGEtype: enumdefault: consensus
How a currency several sources carry is resolved. 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_PROVIDERtype: string
Deprecated single-provider selection. Still honoured, but it pins that provider to the front of the priority list rather than disabling the others. Prefer APP_FIAT_RATES_PROVIDERS.
APP_OPENEXCHANGERATES_APP_IDtype: secret
OpenExchangeRates app ID.
APP_EXCHANGERATE_API_KEYtype: secret
ExchangeRate-API key.
APP_FIAT_RATES_UNITStype: string
Comma-separated 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.

APP_PUBLIC_URLtype: url
Base URL several gateways use to build return and webhook URLs. Set it to your public origin.

Stripe, PayPal, Paystack

APP_STRIPE_PUBLIC_KEYtype: string
Stripe publishable key.
APP_STRIPE_SECRET_KEYtype: secret
Stripe secret key.
APP_STRIPE_WEBHOOK_SECRETtype: secret
Stripe webhook signing secret (whsec_…), from the endpoint you create in the Stripe dashboard. Without it the webhook is refused with 503, not trusted — the route is public and it credits wallets. Payments then confirm only when the customer's browser returns, so a closed tab is a charge with no credit.
NEXT_PUBLIC_APP_PAYPAL_CLIENT_IDtype: stringscope: rebuild
PayPal client ID. Public — it reaches the browser.
APP_PAYPAL_CLIENT_SECRETtype: secret
PayPal client secret.
APP_PAYPAL_WEBHOOK_IDtype: string
The ID of the webhook you create in the PayPal developer dashboard — not a secret. PayPal signs with a certificate, so the platform verifies by posting the headers back to PayPal; the id is what identifies which webhook to verify against. Without it the webhook is refused with 503.
APP_PAYSTACK_SECRET_KEYtype: secret
Paystack secret key.
APP_PAYSTACK_PUBLIC_KEYtype: string
Paystack public key.
APP_PAYSTACK_SANDBOXtype: booleandefault: false
Set to true for the Paystack test environment. Unset means live — the code reads === "true", so anything else is production.
APP_PAYSTACK_RETURN_URLtype: url
Where Paystack sends the customer after payment.
APP_PAYSTACK_WEBHOOK_ENDPOINTtype: url
Paystack webhook target.

TransFi (fiat on/off-ramp)

Sandbox and production credentials are not interchangeable: sandbox credentials return UNAUTHORIZED_CUSTOMER against api.transfi.com, and vice versa.

APP_TRANSFI_USERNAMEtype: string
TransFi API username, from Displai → Settings → Integration.
APP_TRANSFI_PASSWORDtype: secret
TransFi API password.
APP_TRANSFI_MIDtype: string
TransFi merchant ID.
APP_TRANSFI_WEBHOOK_SECRETtype: secret
Shared secret used to verify inbound webhook signatures.
APP_TRANSFI_BASE_URLtype: urldefault: https://sandbox-api.transfi.com
Explicit environment override rather than deriving from 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.
APP_TRANSFI_PURPOSE_CODEtype: stringdefault: personal
purposeCode sent on every order. Must be one of the 58 values TransFi accepts.
APP_TRANSFI_PURPOSE_REASONtype: string
Required when the purpose code is other. Minimum 10 characters.
APP_TRANSFI_SIGNATURE_VARIANTtype: enum
Pin the webhook HMAC canonicalisation once you have observed it in an environment: raw (recommended) or python. Unset means try raw, then fall back and warn.
APP_TRANSFI_SANDBOXtype: boolean
false forces production when the base URL is unset.
APP_TRANSFI_TIMEOUT_MStype: numberdefault: 30000
Per-request timeout in milliseconds.
APP_TRANSFI_CONFIG_CACHE_MStype: numberdefault: 600000
How long currency and method discovery is cached, in milliseconds.
APP_TRANSFI_SCREENING_POLL_MStype: numberdefault: 6000
How long a first deposit waits inside the request for TransFi to finish screening a new payer, in milliseconds.
APP_TRANSFI_SCREENING_RETRY_SECONDStype: numberdefault: 30
Retry hint handed back to the client, in seconds.

The other twelve gateways

APP_2CHECKOUT_MERCHANT_CODEtype: string
2Checkout merchant code.
APP_2CHECKOUT_SECRET_KEYtype: secret
2Checkout secret key.
APP_2CHECKOUT_ACCOUNT_REFERENCEtype: string
2Checkout account reference.
APP_ADYEN_API_KEYtype: secret
Adyen API key.
APP_ADYEN_CLIENT_KEYtype: string
Adyen client key, used by the browser component.
APP_ADYEN_MERCHANT_ACCOUNTtype: string
Adyen merchant account name.
APP_ADYEN_HMAC_KEYtype: secret
Adyen HMAC key for webhook verification.
APP_ADYEN_ENVIRONMENTtype: enumdefault: test
test or live.
APP_AUTHORIZENET_API_LOGIN_IDtype: string
Authorize.Net API login ID.
APP_AUTHORIZENET_TRANSACTION_KEYtype: secret
Authorize.Net transaction key.
APP_AUTHORIZENET_SIGNATURE_KEYtype: secret
Authorize.Net signature key for webhook verification.
APP_DLOCAL_X_LOGINtype: string
dLocal login. dLocal picks sandbox or production from NODE_ENV, not from a flag of its own.
APP_DLOCAL_X_TRANS_KEYtype: secret
dLocal transaction key.
APP_DLOCAL_SECRET_KEYtype: secret
dLocal secret key.
APP_EWAY_API_KEYtype: secret
eWAY API key.
APP_EWAY_API_PASSWORDtype: secret
eWAY API password.
APP_EWAY_WEBHOOK_SECRETtype: secret
eWAY webhook signing key, from MYeWAY. Without it the webhook is refused with 503. The handler re-reads every transaction from the eWAY API rather than trusting the payload, so no amount in the notification is believed.
APP_IPAY88_MERCHANT_CODEtype: string
iPay88 merchant code.
APP_IPAY88_MERCHANT_KEYtype: secret
iPay88 merchant key.
APP_KLARNA_USERNAMEtype: string
Klarna API username.
APP_KLARNA_PASSWORDtype: secret
Klarna API password.
APP_KLARNA_WEBHOOK_SECRETtype: secret
Klarna webhook shared secret.
APP_MOLLIE_API_KEYtype: secret
Mollie API key.
APP_MOLLIE_RETURN_URLtype: url
Where Mollie returns the customer.
APP_MOLLIE_WEBHOOK_ENDPOINTtype: url
Mollie webhook target.
APP_PAYFAST_MERCHANT_IDtype: string
PayFast merchant ID.
APP_PAYFAST_MERCHANT_KEYtype: secret
PayFast merchant key.
APP_PAYFAST_PASSPHRASEtype: secret
PayFast passphrase, used in the signature.
APP_PAYFAST_SANDBOXtype: booleandefault: false
Set to true for the PayFast sandbox. Unset means live — the code reads === "true", so anything else is production.
APP_PAYFAST_RETURN_URLtype: url
PayFast success return URL.
APP_PAYFAST_CANCEL_URLtype: url
PayFast cancel return URL.
APP_PAYFAST_NOTIFY_URLtype: url
PayFast ITN notify URL.
APP_PAYSAFE_API_KEYtype: secret
Paysafe API key.
APP_PAYSAFE_API_SECRETtype: secret
Paysafe API secret.
APP_PAYSAFE_ACCOUNT_IDtype: string
Paysafe account ID.
APP_PAYSAFE_SANDBOXtype: booleandefault: false
Set to true for the Paysafe test environment. Unset means live — the code reads === "true", so anything else is production.
APP_PAYSAFE_RETURN_URLtype: url
Paysafe return URL.
APP_PAYSAFE_WEBHOOK_ENDPOINTtype: url
Paysafe webhook target.
APP_PAYTM_MIDtype: string
Paytm merchant ID.
APP_PAYTM_MERCHANT_KEYtype: secret
Paytm merchant key.
APP_PAYTM_WEBSITEtype: string
Paytm website value, e.g. WEBSTAGING in test.
APP_PAYTM_INDUSTRY_TYPEtype: string
Paytm industry type ID.
APP_PAYTM_SANDBOXtype: booleandefault: false
Set to true for the Paytm staging environment. Unset means live — the code reads === "true", so anything else is production.
APP_PAYTM_CALLBACK_URLtype: url
Paytm callback URL.
APP_PAYTM_WEBHOOK_ENDPOINTtype: url
Paytm webhook target.
APP_PAYU_MERCHANT_IDtype: string
PayU merchant ID.
APP_PAYU_MERCHANT_KEYtype: secret
PayU merchant key.
APP_PAYU_MERCHANT_SALTtype: secret
PayU merchant salt, used in the request hash.
APP_PAYU_SANDBOXtype: booleandefault: false
Set to true for the PayU test environment. Unset means live — the code reads === "true", so anything else is production.
APP_PAYU_SUCCESS_URLtype: string
Path appended to FRONTEND_URL for a successful PayU payment.
APP_PAYU_FAILURE_URLtype: string
Path appended to FRONTEND_URL for a failed PayU payment.
APP_PAYU_CANCEL_URLtype: string
Path appended to FRONTEND_URL when the customer cancels.
APP_PAYU_CALLBACK_URLtype: string
PayU callback path.
APP_PAYU_WEBHOOK_ENDPOINTtype: url
PayU webhook target.

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.

APP_OANDA_API_KEYtype: secret
OANDA API key.
APP_METAAPI_TOKENtype: secret
MetaApi access token.

AI services

GEMINI_API_KEYtype: secret
Google Gemini API key. Used by the AI KYC verification path and the health check.
DEEPSEEK_API_KEYtype: secret
DeepSeek API key, the alternative AI verification provider.
OPENAI_API_KEYtype: secret
Declared in the template but read by nothing — see the dead-config list below.

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="..."

# Optional, and worth setting: a second endpoint the chain falls back to.
ETH_MAINNET_RPC_FALLBACK="https://backup-1.example, https://backup-2.example"

<SYMBOL>_<NETWORK>_RPC accepts a comma-separated list, so you can also put several endpoints in the key you already have without learning a new one. Both keys are read and combined, in order, and duplicates are dropped.

Give every chain a second endpoint if you can. Deposits, withdrawals, balance reads and swap broadcasting all run through the same provider, so one rate-limited or briefly-down node takes all of them offline at once — and free-tier public endpoints rate-limit constantly. With more than one configured the platform orders them by measured latency, drops one that fails three times in a row, and brings it back on its own when it answers again. With exactly one it behaves as it always has. See the chain RPC runbook.

<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).

BTC_NETWORKtype: enumdefault: mainnet
Bitcoin network selector.
BTC_NODEtype: stringdefault: mempool
Which Bitcoin data source the deposit scanner uses.
BLOCKCYPHER_TOKENtype: secret
BlockCypher token, used by the UTXO provider when BlockCypher is the selected node.
ETHERSCAN_API_KEYtype: secret
Etherscan API V2 multichain key, used for any EVM chain with no <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.
ENABLE_DEPOSIT_MONITORINGtype: booleandefault: false
Set to 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.

ANKR_API_KEYtype: secret
Ankr Advanced API key. Covers most mainnets on a free tier; does not index BSC testnet.
MORALIS_API_KEYtype: secret
Moralis key. Covers the major EVM chains and their testnets.
COVALENT_API_KEYtype: secret
Covalent / GoldRush bearer token. One of the two providers that index BSC testnet — Ankr and NodeReal do not.
NODEREAL_API_KEYtype: secret
NodeReal key. ETH and BSC mainnet only, and the free replacement for the retired BscScan API.
BLOCKSCOUT_API_KEYtype: secret
Optional. Blockscout serves without a key; this only lifts the anonymous per-IP rate limit.
ROUTESCAN_API_KEYtype: secret
Optional. Routescan serves without a key; this only lifts the anonymous per-IP rate limit.
TRANSACTION_PROVIDERStype: string
Comma-separated provider order for every chain, overriding the built-in per-chain defaults. Unknown names are dropped with a warning.
TRANSACTION_PROVIDERS_<CHAIN>type: string
Comma-separated provider order for one chain, e.g. TRANSACTION_PROVIDERS_BSC. Beats TRANSACTION_PROVIDERS and the built-in default.
TRANSACTION_PROVIDERS_STRICTtype: booleandefault: false
Set to true to stop the keyless Blockscout/Routescan tail being appended to the order you configured.
TRANSACTION_PROVIDER_TIMEOUT_MStype: numberdefault: 12000
Per-attempt provider timeout in milliseconds. Values below 1000 are ignored.
TRANSACTION_PROVIDER_LIMITtype: numberdefault: 1000
Records requested per provider call, 1-10000. Etherscan's free tier caps at 1,000; Moralis and Ankr cap a page at 100 regardless, and NodeReal at 100 — it asks for 50 incoming and 50 outgoing transfers in two calls, then merges and deduplicates them by hash.
<CHAIN>_BLOCKSCOUT_HOSTtype: string
Host of a self-hosted Blockscout instance for one chain, e.g. BSC_BLOCKSCOUT_HOST. Beats the built-in chain-id map.

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.mjs

It 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).

ENCRYPTED_ENCRYPTION_KEYtype: secret
The encrypted master encryption key. Four colon-separated hex parts.
ENCRYPTION_KEY_PASSPHRASEtype: secret
The passphrase that decrypts it, at least 12 characters. Both are held in memory only for the life of the process.

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.

SCYLLA_CONNECT_POINTStype: stringdefault: 127.0.0.1:9042
Comma-separated contact points. Defaults to a local node.
SCYLLA_DATACENTERtype: stringdefault: datacenter1
Local datacenter name, as Scylla reports it.
SCYLLA_USERNAMEtype: string
Scylla username, if the cluster requires authentication.
SCYLLA_PASSWORDtype: secret
Scylla password.
SCYLLA_KEYSPACEtype: stringdefault: trading
Keyspace holding ecosystem orders, candles, order book, trades and stop orders.
SCYLLA_FUTURES_KEYSPACEtype: stringdefault: futures
Keyspace holding futures orders, positions, order book and candles.
SCYLLA_LOCAL_CONNECTIONStype: number
Connections per host in the local datacenter pool.
SCYLLA_ENABLEDtype: booleandefault: true
Set to 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

MAIN_PRODUCT_IDtype: stringdefault: 35599184
The core product ID used for license checks and heartbeats. Leave it alone unless support tells you otherwise.
LICENSE_SECRETtype: secret
Signing secret for the machine-bound .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.
HEARTBEAT_INTERVALtype: numberdefault: 3600000
How often the license heartbeat runs, in milliseconds. Clamped to between 5 minutes and 1 hour. Egress to 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.

NEXT_PUBLIC_2FA_EMAIL_STATUStype: booleandefault: false
Legacy fallback enabling email one-time codes at login and on withdrawals.
NEXT_PUBLIC_2FA_SMS_STATUStype: booleandefault: false
Legacy fallback enabling SMS one-time codes.
NEXT_PUBLIC_2FA_APP_STATUStype: booleandefault: false
Legacy fallback enabling authenticator-app codes on withdrawals.

KYC document storage

KYC_DOCUMENT_DIRtype: path
Where identity documents are written. Defaults to backend/storage/kyc/documents. Set it to move the store onto a separately backed-up volume — which was impossible before 6.7.2, when documents lived in the public web root. Set it before running pnpm db:migrate:6.7.3:apply, or the migration moves documents somewhere the server does not look. Include this directory in your backups.

Other operational variables

None of these are in .env.example either, but several change behaviour you can observe.

FRONTEND_URLtype: url
Base URL for payment-gateway return paths. See the PayU warning above.
SUMSUB_API_KEYtype: secret
Sumsub API key, for the Sumsub KYC verification service.
SUMSUB_API_SECRETtype: secret
Sumsub API secret.
MAIL_DISABLEDtype: booleandefault: false
Set to true, 1, yes or on to suppress all outbound mail. Useful on a staging clone of production data.
APP_EMAIL_FROMtype: email
From address used by the notification service's email providers, distinct from the transport sender.
APP_NODEMAILER_SMTP_USERNAMEtype: string
SMTP auth user when it differs from the sender address. Falls back to APP_NODEMAILER_SMTP_SENDER.
HB_TRUST_PROXYtype: booleandefault: unset
LEGACY. The Hummingbot addon now uses the platform's own client-IP resolver, so this needs no value on a new install — a proxy on this machine is trusted automatically. Kept as an alias so an existing install that set it keeps working; TRUST_PROXY wins when both are present.
LOG_LEVELtype: enumdefault: info
Minimum log level. debug also turns on verbose API request logging.
SLOW_REQUEST_MStype: numberdefault: 3000
How long a request may take before the log prints a second line naming where its time went — the four slowest steps of that request, with their durations. Nothing is printed below the threshold, so a healthy install is silent. Set it to 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.
WALLET_TX_CONCURRENCYtype: numberdefault: half of DB_POOL_MAX
How many wallet ledger writes (holds, releases, credits, debits, cancel refunds) may hold a database connection at once in one backend process. Writes to the same wallet always run one at a time; this caps the total across wallets so a burst of bot orders cannot take the whole pool and stall every other query. Defaults to half of DB_POOL_MAX, never fewer than four.
WALLET_QUEUE_TIMEOUT_MStype: numberdefault: 30000
How long a wallet ledger write may wait for its wallet's turn, and for a connection slot, before it is refused with WALLET_BUSY (503) instead of hanging. Matches the pool's own acquire timeout by default.
ECO_BOOK_FRAME_INTERVAL_MStype: numberdefault: 200
How long ecosystem order-book changes are gathered before ONE websocket frame is read and sent, per market. Placements, cancellations and fills ask for a frame rather than reading the book themselves, so a burst of bot quotes costs one read instead of one per event; the frame that goes out is read after the burst and is therefore fresher than any it replaced. 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.
GOOGLE_TRANSLATE_API_KEYtype: secret
Google Translate API key, surfaced in the system health check.
UPLOAD_DIRtype: path
Absolute path overriding where downloadable e-commerce product files are stored.
P2P_ATTACHMENT_DIRtype: path
Absolute path overriding where P2P dispute attachments are stored.
BACKUP_PATHtype: path
Where the NFT blockchain backup service writes. Defaults to backups/nft under the project root.
BACKUP_ENCRYPTION_KEYtype: secret
Encryption key for those backups. Empty means unencrypted.
NEXT_PUBLIC_BINARY_PROFITtype: numberdefault: 87
Inert. Read once into a constant in the binary cancel-order route and never used from there; cancellation refunds are priced from the Cancellation tab of binary settings. The 87 is the code's fallback, not a shipped entry.
API_THRESHOLDtype: numberdefault: 100
Batch size the ecosystem deposit monitor requests per API poll.

Order admission and fail-fast

Everything in this group is off unless you set it, and with every variable unset the order path is byte for byte what it was before. Each one bounds a queue that a burst of bot orders could otherwise grow without limit, and refuses the request in front of it instead of letting it wait: a refusal written before the body is read costs the process a few tens of microseconds, where an order that queues for thirty seconds and then fails costs the process 8 to 9 milliseconds of CPU and stalls every other route on the way. The numbers quoted below were measured on the reference box (one backend process, tuned MariaDB, quiet): it accepts about 50 to 62 place-and-cancel pairs per second from bots (each pair is two requests through the gate), about 100 placements per second on an empty book, and one core is spent at 110 to 125 accepted requests per second. Your own figures come from the same probes, GET /api/admin/ecosystem/engine/health and the [ECO_ADMISSION] log line, both described in Monitoring.

Two doors take orders: the session door (/api/ecosystem/order*, the trading screen and the mobile app) and the signed Hummingbot door (/api/hb/order*). A refusal renders on each in the shape that door already uses. The session door answers HTTP 200 with {message, statusCode} in the body, as it does for every refusal today, plus a Retry-After header. The Hummingbot door answers a real status with {code, msg}: 429 as -1003, 503 as -1001. Nothing changes for a request that is accepted.

Unless an entry says otherwise the value is read on every request, so a change takes effect without a restart; pnpm env-manager set NAME=value --restart is still the safe way to write it.

The shed gate, before the body is read

ECO_ADMISSION_MODEtype: enumdefault: off
Whether the shed gate runs on the two trade doors. off leaves the doors exactly as before: no sampler, no counters, nothing on the request path. log takes every decision and counts it but refuses nothing, printing one [ECO_ADMISSION] warning line per second whenever something would have been refused; run this first and read that line for a day before you enforce anything. enforce writes the refusals. The gate sits before the body is read, before authentication and before any database or Redis call, which is what makes a refusal cheap: 30 to 40 microseconds rendered, against the 670 to 900 microseconds an order that reached the balance check and failed there used to cost. Read when the process starts; restart to change it.
ECO_ADMIT_PER_SECtype: numberdefault: 0
Requests per second through the gate, one token bucket over BOTH trade doors and BOTH methods: a placement (POST) and a cancellation (DELETE) each spend one token, on /api/ecosystem/order* and /api/hb/order* alike. The unit is therefore requests through the gate, not placements: a bot fleet placing and cancelling 55 orders per second is 110 requests per second here. The bucket holds one second of admissions and refills with the clock. 0 is unlimited, and the only sensible value while nothing has been measured. Above the budget the request is refused with 429 (-1003 on the Hummingbot door) carrying X-RateLimit-Bucket: admission, X-RateLimit-Limit, X-RateLimit-Remaining: 0, X-RateLimit-Reset and Retry-After. Set it at the ceiling of your own box in requests per second, read from admitted on the [ECO_ADMISSION] line, which counts the same requests. On the reference box that ceiling was 111 requests per second with the box loaded and 209 with it quiet (55 to 104 placements per second with as many cancels beside them), and the value is load-dependent by that 2x: a budget sized for the quiet box refuses nothing while the box is quiet and lets the queue return when it is not, which is why the loop-delay signal below exists. A budget above the ceiling does not shed; a burst at 600 per second against a budget of 110 on the loaded box held one request open for 5.3 s with 28 queries waiting on the pool, while a budget of 70 kept every request under 3 s. Read when the process starts.
ECO_ADMIT_REFUSALS_PER_TURNtype: numberdefault: 256
How many refusals the gate writes in one pass over the event loop before it yields. Every connection that became ready at the same moment is handed to the process in one turn, so a burst of ten thousand refused connections would otherwise hold the loop for the whole burst and every other route with it. The default is right; lower it only if a connection storm is measurably stalling unrelated routes. Read when the process starts.
ECO_ADMIT_LOOP_DELAY_MStype: numberdefault: 0
Mean event-loop delay, in milliseconds over the current one-second window, above which the gate refuses with 503 (-1001, Retry-After: 1) because the process is already behind. This is the adaptive half of the gate: ECO_ADMIT_PER_SEC is a fixed number that fits one load, and on a box that is shared or busy it is the loop delay that says the process is behind whatever the budget allows. 0 leaves the sampler observing only. Two facts set the floor. The sampler's histogram has a 20 ms resolution, so an idle loop reads a mean of about 30 ms, never zero; and on the reference box the mean sat at 20 to 34 ms at every load measured, with the p99 at 48 to 58 ms on a shallow book and 65 to 128 ms with four to five thousand resting orders. A threshold has to sit at about 50 ms or above the 30 ms idle floor to shed anything real; under 50 it sheds a healthy process, and 150 or more catches only a real stall. Read the figures from eventLoopDelay on the engine health route before choosing. Read when the process starts.
ECO_ADMIT_INFLIGHT_PER_DOORtype: numberdefault: 0
How many admitted-and-unanswered requests one door (session or Hummingbot) may hold before the next is refused with 503 (-1001, Retry-After: 1). 0 disables it. On the reference box 40 requests in flight gave a placement p50 of 344 ms and p99 of 909 ms; 80 in flight exhausted the 25-connection pool from the first second (74 waiting) and the slowest wallet wait reached 1.7 s. A value between 40 and 80 per process keeps every accepted order under a second; the pool and wallet figures on the engine health route say where a request open past that would be waiting. Read when the process starts.

One caller at a time

ECO_INFLIGHT_PER_KEYtype: numberdefault: 0
How many requests one caller may have open on the trade doors at once, checked before the Trade budget is charged so a refused request costs the bot nothing from its window. The caller is the API key on the Hummingbot door and the signed-in account on the session door, where the same cap also fronts the DEX swap and forex order doors. Over the cap the request is refused with 429 (-1003), the X-RateLimit-* headers read without spending, Retry-After: 1 and its own message (Too many requests in flight for bucket 'trade' (limit N). Retry shortly. for a bot, Too many orders in flight. Please wait for your open requests to finish. for a person). 0 is off. 4 is the working value: a Hummingbot strategy keeps one or two orders in flight per tick, and four open requests on a door answering in 88 ms (the reference box's p50) still let one bot place about 45 orders per second on its own, most of what the whole process accepts. Read on every request.

The wallet gate

These two sit beside WALLET_TX_CONCURRENCY and WALLET_QUEUE_TIMEOUT_MS above. The deadline refuses a write that has already waited thirty seconds; these refuse a hold, the write a new order takes on the wallet it spends, the moment it arrives at a queue that is already long, so a hundred thousand holds do not each arm a thirty-second timer and fail together late. Only holds are refused. A release, a cancel refund, a fee credit, a transfer, a settlement leg or an admin adjustment is never refused at entry, because a refund that was refused would leave the ledger different from today.

WALLET_QUEUE_MAX_PER_KEYtype: numberdefault: 0
How many ledger writes may already be waiting for one wallet before a new hold on that wallet is refused without queueing. The refusal is WALLET_BUSY (503) with The wallet is busy: N ledger writes are already waiting for it and this one was refused without waiting. Please retry. A refused hold inside a placement still rolls the order back, so the trading screen sees the placement's own rollback message unless ECO_PREHOLD_ADMISSION catches it first. 16 is the working value: on the reference box the deepest queue on one wallet under a placement flood was 41 writers and its longest wait 1,719 ms, while a quiet bot mix on five wallets queued one to five; sixteen writers ahead of a hold is a few hundred milliseconds of wallet time, well inside the deadline, and a queue past that is a bot re-quoting faster than its wallet can commit. 0 is unbounded. Read on every hold.
WALLET_QUEUE_MAX_SLOTtype: numberdefault: 0
How many ledger writes may already be waiting for a database connection slot (the pool of WALLET_TX_CONCURRENCY) before a new hold is refused without queueing, with the same WALLET_BUSY (503). Checked once, at entry, so a hold admitted past it is an ordinary waiter afterwards. On the reference box the slot queue reached 26 with 80 requests in flight against a pool of 25, which is the point where the pool has nothing left for any other query; about twice WALLET_TX_CONCURRENCY keeps the queue below that. 0 is unbounded. Read on every hold.
ECO_PREHOLD_ADMISSIONtype: booleandefault: false
Set to 1, true, on or yes to ask the wallet gate, immediately before the order is written to ScyllaDB, whether the hold it is about to take would be refused at entry, and to refuse the placement there with 503 instead: no order row is written and nothing is rolled back. The refusal is the same WALLET_BUSY message the gate would have produced. In this release the check consults the connection-slot bound (WALLET_QUEUE_MAX_SLOT); the per-wallet bound is still applied by the hold itself, after the row is written, as before. Off, the placement path is untouched. Read on every placement.

The matching engine and ScyllaDB

ECO_CLAIM_TIMEOUT_MStype: numberdefault: 0
How long a cancellation may wait for the matching engine's lock before it is refused, in milliseconds. A cancel claims the order under the same lock the matching cycle holds, so on a deep market it waited behind the whole cycle with no limit. Past the bound the door answers 503 (-1001, Retry-After: 1) with The matching engine is busy: <operation> waited N ms for the engine lock. Nothing was changed and the order is still open; retry. The order really is untouched: nothing was written and the claim never ran. A Hummingbot cancel-all reports it per order under failed[].reason with a 200, as it reports every per-order failure. 0 disables the bound. On the reference box the claim itself costs 1.7 ms with 50,000 resting orders and 3.7 ms with 100,000, one matching cycle is well under a second, and the cancel p99 on the deepest shape was 909 ms, so a value of 1000 to 2000 refuses only a cancel that is stuck behind a stalled cycle. Read on every cancel.
ECO_CANCEL_DRAIN_PER_TICKtype: numberdefault: 0
How many cancellations placed through another backend process the engine leader serves per one-second drain tick. An order placed on a process that does not run the matching engine is cancelled by a request the leader picks up once a second; with no bound every due request is served in the tick that finds it. Requests past the bound stay queued untouched, with no attempt recorded, and are served in the following ticks; every ten seconds the log prints Served N cross-process cancel(s) this tick (ECO_CANCEL_DRAIN_PER_TICK=L); M wait for the next tick. 0 is unbounded. Only relevant to an install running more than one backend process; on the reference box the cancel-all door spends 13 to 18 ms of leader time per order, so a bound of 50 keeps a tick under a second. Read on every tick.
ECO_SCYLLA_BUDGET_PLACEtype: numberdefault: 0
How many placements may have ScyllaDB work in flight in this process at once. The order write runs inside the gate, so at the bound the placement is refused at entry with 503 (-1001, Retry-After: 1) and The order store is busy: ... before any row exists: no order row, no index row, no hold, nothing to roll back, and the message's promise that nothing was changed is literally true. The rollback of a placement whose hold was refused afterwards is counted against the same gate but never refused by it, because a refused rollback would strand a funded OPEN order on money the customer was told they did not spend. 0 is unbounded and only counts. Nothing surfaces the gate's counters on a route in this release, so set the bound from your ScyllaDB's own capacity rather than from a figure the platform reports, and treat the message appearing under normal load as the bound being too low. Read on every placement.
ECO_SCYLLA_BUDGET_CANCELtype: numberdefault: 0
The cancellation half of the same gate: how many cancellations may be inside their release-and-mark section (the wallet refund, the CANCELED status write and the price-level decrement) at once in this process. Both user doors, DELETE /api/ecosystem/order/{id} and the Hummingbot cancel, and every caller of the shared cancel helper (cancel-all per order, copy-trading close, OCO siblings, stop orders, the IOC sweep, the cross-process drain) run inside it. At the bound the cancel is refused at entry with 503 and Retry-After: 1, the claim is restored, and the order is exactly as it was: OPEN, funded, resting and claimable again; the message is never one Hummingbot reads as already gone. The claim's own bound (ECO_CLAIM_TIMEOUT_MS) stays in front of the gate and the matching cycle is kicked outside it, so a slot is never held across a cycle. In the IOC sweep a refused remainder stays resting for the next sweep. The slot counts the refund's MySQL time as well as the ScyllaDB writes. 0 is unbounded. Read on every cancel.

The engine flags, behind a canary

The variables in this subsection change how the matching engine does its work without changing what it writes: the same fills in the same sequence, the same ledger rows with the same keys and amounts, the same wire responses, with a flag on or off. Each one exists because a cost that used to grow with the depth of the book (a walk over every resting order per cycle, a pass over every market per placement, a reconciliation that held the engine lock across the whole book) was measured and replaced, and each one is off until you turn it on. They are read through one registry inside the backend, and four rules hold for all of them:

  • Off by default. A backend with none of them set runs the engine it ran before they existed, byte for byte.
  • Confined to a canary. ECO_ENGINE_CANARY_SYMBOLS names the markets a flagged behaviour may run on; empty means every market. Start with one quiet market and widen it.
  • Money-affecting flags fail off. The flags marked money-affecting below read as off, without a restart, whenever the process cannot hear a kill: during boot until its settings-bus subscription lands, and whenever that Redis subscription is lost. The cost-only flags stay as configured, because losing one costs throughput, not rows. The registry also carries a kill switch on the settings-bus channel eco:scale:kill that turns every flag off in every process at once; in this release no admin screen or command publishes it, so the rollback you will actually use is to unset the variable and restart the process.
  • Mirrors are proven or not trusted. The v2 book keeps second copies of the engine's resident orders; ECO_ENGINE_SELF_CHECK compares each copy against the original at every place the engine changes them, and a copy that diverges is reported before it can price anything.

All of them are read when the process starts, so change them with pnpm env-manager set NAME=value --restart. A flag reaching the engine is announced once in the log, per flag, with the lines quoted under each entry; scaleFlags on the engine health route shows every flag as configured (what the environment says) and effective (what the engine is doing right now), and both are described in Monitoring.

The order to turn them on is the order below, one at a time, on the trading process:

  1. Arm the check and pick the canary. Set ECO_ENGINE_SELF_CHECK=log and ECO_ENGINE_CANARY_SYMBOLS to one market that trades but does not carry your volume, then restart.

  2. Turn one flag on, restart, and read the announcement line for it in the log. GET /api/admin/ecosystem/engine/health on the trading port should show the flag configured: true, effective: true; effective: false with the variable set means the process was killed or cannot hear a kill yet, and the flag is doing nothing.

  3. Watch for a day. An error line tagged ECO_SELF_CHECK is a mirror that disagreed with the engine's own list; Self-check DISABLED itself after three of them is the checker giving up on the mirrors. Either one is "turn the flag off and report it", not "raise the limit".

  4. Widen the canary, then repeat from step 2 for the next flag. Leave the self-check in log mode on the canary as long as you like; its cost is a linear pass over the market's resident orders at each check site, which is what the flags exist to avoid on the markets outside the canary.

ECO_ENGINE_CANARY_SYMBOLStype: stringdefault: unset
Comma-separated market symbols (BTC/USDT,ETH/USDT), trimmed and upper-cased, that every engine flag below is confined to. Empty or unset is no restriction: the flags alone decide, on every market. A symbol spelt in lowercase still matches, because the engine stores symbols upper-cased and a canary that silently matched nothing would be a rollback that did not roll. Also confines the placement-side reader of the v2 book. Read at boot.
ECO_ENGINE_SELF_CHECKtype: enumdefault: off
Whether the v2 book's mirror structures are checked against the engine's own list of resident orders. off runs no check. log compares them at every check site (the start and end of every matching cycle, every cancel claim, every restore of a claimed order, every resync ingest, and the aggregate reconciler's dual check) and writes one error line tagged ECO_SELF_CHECK per divergence, naming the site, the mirror and both values; after three divergences the checker disables itself with Self-check DISABLED itself at <site> after N divergences (limit 3). The mirrors are not trusted; the linear derivation is no longer paid for. Restart the process after the cause is fixed. and stops paying for the comparison. throw makes the first divergence throw from the mutation site; it exists for the test gates and must never be set in production, because an engine that throws under its own lock is worse than an engine with a stale mirror. The check has nothing to compare unless ECO_ENGINE_BOOK_V2 is on for the market. Read at boot.
ECO_ENGINE_BOOK_V2type: booleandefault: false
Money-affecting. On a canary market the engine builds a second structure beside its list of resident orders the first time it needs one: an id map, a per-user set of open orders, a per-price aggregate, and the two sides as a tree of price levels in the exact order the walk sorts them, kept in step at every place the list changes. Depth reads answer from the aggregate, a placement's duplicate check and a cancel's claim find the order by id, and the placement's self-match guard reads the caller's own open orders from the per-user set instead of paging their history from ScyllaDB (see the order desk for the one case that answers differently). The matching walk itself still runs over the list in this release. Costs about 200 bytes of heap per resident order. Announced per market as Resident book v2 ON for <symbol> (ECO_ENGINE_BOOK_V2): built from N resident order(s) and Resident book v2 OFF for <symbol>: dropped, the array is the only structure again. Required by ECO_RECONCILE_FROM_AGGREGATE. Read at boot.
ECO_CYCLE_DIRTY_ONLYtype: booleandefault: false
Money-affecting. Every placement used to make the next matching cycle visit every market. With this on, the engine marks a market whenever its resident orders change (a placement, a cancel claim or its restore, a completed cancellation, an eviction, a window refill, a resync, a cycle that filled something) and a cycle visits only the marked markets inside the canary, clearing each mark under the engine lock before it walks the market; every ECO_CYCLE_FULL_PASS_MS a cycle visits every market again as the backstop. Markets outside the canary are visited by every cycle as before. Announced once as Dirty-symbol cycles ON (ECO_CYCLE_DIRTY_ONLY): cycles visit marked symbols, all of them every N ms and Dirty-symbol cycles OFF: cycles visit every symbol. Read at boot.
ECO_CYCLE_FULL_PASS_MStype: numberdefault: 5000
Only acts while ECO_CYCLE_DIRTY_ONLY is on: how long, in milliseconds, a market may go unvisited before a cycle walks every market again. 0 makes every cycle a full pass, which costs exactly what the flag off costs; a fractional value is floored; empty, negative or unparseable reads as the default. Cost-only: it decides when an untouched market is re-walked, and a walk over unchanged orders settles nothing. Read on every cycle.
ECO_MATCH_EARLY_BREAKtype: booleandefault: false
Cost-only. A cycle on a deep market that fills nothing still walked both sides to prove it. With this on, once both cursors are past their side's last market order and the best bid is strictly below the best ask, the walk stops. The break is placed so that the fill sequence is identical with it on or off (it is evaluated after an already-processed order is skipped and before the same-party test, and the head test uses the same comparison the sort uses), and a test oracle runs the real matcher both ways on every fixture to prove it. Measured on the reference box: a zero-fill cycle at 50,000 resting orders dropped from 33.8 to 23.4 ms. Confined to the canary. Not money-affecting, so it stays on while the settings bus is down. Read at boot.
ECO_CANCEL_AWAITS_CYCLEtype: booleandefault: true
Money-affecting, and the one flag whose safe value is on. Unset, blank or anything but 0, false, off or no: a completed cancellation waits for the matching cycle it triggers before answering, as it always has. 0: on a canary market the cancel marks the market, releases its claim, schedules the cycle and answers at once. The refund is sized from the claim either way, the response body is identical, and the fills and ScyllaDB writes of the scheduled cycle are the same as the awaited one; what changes is the cancel's latency on a deep market, which no longer includes a cycle. The sense of the kill switch is reversed for this flag: a kill, or a settings bus that cannot deliver one, lands on the awaited path. Cancel-all keeps its awaited per-market cycle whatever the value. Announced once as Cancel cycles SCHEDULED (ECO_CANCEL_AWAITS_CYCLE=0, first on <symbol>) ... and Cancel cycles AWAITED again .... The engine health route's effective for this name reads the variable, not the engine's behaviour, so it can read false under a kill while the engine awaits. Read on every cancel.
ECO_NUDGE_CARRIES_KEYtype: booleandefault: false
Cost-only. An order placed on a process that does not hold the engine reaches the engine through a nudge over Redis, and the leader answered a nudge by re-reading the market's whole resident window from ScyllaDB under the engine lock, up to 25,001 rows a side. With this on, a nudge that carries the order's key makes the leader read that one row, refuse it unless it is OPEN on that market, and admit it by the same rule as a window row (a price outside the resident band of a windowed market stays in the index for the window to slide to); the once-a-second drain of the durable dirty set reads one window per market per pass and forgets every keyed entry that read covered. In this release the placement path still publishes the bare market symbol, so the leader takes the window read whatever the value: the consumer half is built and the producer half is not. When a later build carries the key, turn it on only once every backend process runs that build, because a leader on this build drains a keyed entry as a market name it does not know and the order waits for the leader's 60-second sweep. Confined to the canary at the engine. The ingest line gains a suffix: Ingested N order(s) for <symbol> placed by another process (keyed nudge). Read at boot.
ECO_WINDOW_BAND_READtype: booleandefault: false
Cost-only. When a side of a windowed market (see ECO_BOOK_WINDOW_PER_SIDE) drops below its low-water mark, the engine refilled it by re-reading the whole window for the market. With this on, on a canary market, the refill reads only the band beyond that side's boundary price, as a clustering-key range on the market's index partition (price < boundary for bids, price > boundary for asks, inclusive when the boundary level is only partly resident), with a per-side limit of the window minus what that side still holds, through the same split-level rule the boot load uses. The one observable difference is a display one: an order another process placed inside the resident band is picked up by its nudge, the drain or the 60-second sweep rather than by the next refill. The window-slide line gains through a band read beyond the boundary. Read at boot.
ECO_RESYNC_READ_OUTSIDE_LOCKtype: booleandefault: false
Cost-only. A resync (a nudge, the drain, the 60-second sweep) read its ScyllaDB rows while holding the engine lock, so every placement and cancel on every market waited behind that read. With this on, on a canary market, the read runs before the lock is taken; under the lock the result is merged only if nothing changed the market in the meantime (every placement, claim, restore, cancel, fill, eviction and window refill moves a per-market counter, and an order mid-cancellation is skipped by the ingest as well), and otherwise it is discarded and the market is read again under the lock, which is the old path. A busy market therefore pays what it paid before and never a wrong merge; a quiet one stops blocking the others. Read at boot.
ECO_RECONCILE_FROM_AGGREGATEtype: booleandefault: false
Money-affecting. The five-minute reconciliation that repairs the aggregated order-book levels in ScyllaDB against the engine's resident orders held the engine lock for the whole round, across a linear conversion of every resident order and a partition read per market. With this on, for every canary market that has a v2 book, the round takes one short hold of the lock to copy the book's per-price aggregate (with the orders mid-cancellation folded back in, exactly as the old sweep counts them), reads the market's stored levels and plans the repairs off the lock, and writes each repair under a short hold that first checks the market's mutation counter is still what the copy saw; a repair whose market moved is refused, the market is copied again, and after three refusals it is left for the next round with an info line. Every other market, and every market without a book, is swept the old way after them. With ECO_ENGINE_SELF_CHECK armed the round also derives the same levels linearly and, on a disagreement, repairs from the engine's list rather than from the copy and reports it. The repairs are the same rows with the same amounts as the old sweep; only the order of the inserts inside one market's list of repairs differs. A kill, or a settings bus that cannot deliver one, lands on the old sweep under the lock. Announced once as Reconciler from the aggregate ON (ECO_RECONCILE_FROM_AGGREGATE): canary symbols with a v2 book ... and Reconciler from the aggregate OFF: the linear sweep runs under the engine lock for every symbol; a round that starts while the previous one is still running logs Orderbook reconciliation round skipped and does nothing. Requires ECO_ENGINE_BOOK_V2. Read at boot.

Cancel-all in batches

ECO_CANCEL_ALL_BATCHtype: numberdefault: 0
Orders per wallet transaction when a customer cancels every open order at once, on the session cancel-all (DELETE /api/ecosystem/order/all) and the Hummingbot batch cancel (DELETE /api/hb/order, up to 200 per call). 0, absent or unparseable is the per-order loop as before: one engine claim of every open order, then one wallet transaction and one ScyllaDB level update per order, about 18.5 ms of leader time each on the reference box. A value (the plan suggests 50) keeps the single claim and then groups the refunds per wallet: one wallet transaction per N orders with a database savepoint per order, so one refused order rolls back only itself and the batch continues; one ScyllaDB batch per price level carrying the sum of the per-order decrements instead of N read-modify-writes; then one matching cycle per touched market. Rows, idempotency keys, amounts and the response body are identical either way; what changes is the commit envelope, and two things that follow from it: a deadlock or lost connection inside a batch fails every order of that batch at once (rolled back, claims restored, each reported failed), and the wallet's row lock is held for the whole batch, 150 to 500 ms at 50, during which that wallet's fills wait. Every batch reads the engine lease's epoch under lock and compares it with the epoch read at the start of the run, so a run that straddles a promotion of another process stops with its remaining orders reported failed and their claims restored, nothing released twice; an install whose lease has never been claimed through MySQL has no epoch row and runs unfenced, which is logged once. The IOC sweep still cancels remainders one at a time in this release. The kill switch forces 0. Read on every call.

The ledger batcher

ECO_LEDGER_BATCHERtype: enumdefault: off
Money-affecting; off, holds or all, and any other value (including 1 or true) reads as off, so a boolean-style setting cannot switch a money path by accident. holds: a placement's hold on the wallet it spends, which used to open a MySQL transaction of its own, is instead submitted to one batcher per process and committed with every other hold of the same tick in one transaction; the placement still waits for that commit before the order becomes matchable. all: additionally every fill's ledger legs (the buyer's and seller's credits and hold drains, and the AI market maker's pool update when a bot is one side) are submitted as one group of the next tick. The rows written are the rows the verbs write today, with the same keys, amounts, types and descriptions, and a replayed key still answers as a duplicate with the existing row's id; what changes is the commit boundary, recorded as three allowlisted differences: a group is applied whole or not at all (a refused leg discards its sibling legs; today each leg was its own transaction), a batch-level failure (a deadlock or lost connection past three retries, an epoch mismatch, a stopped batcher) fails every operation of that tick at once, each placement rolling its order back and rendering as the same 500 a deadlock renders today, and a wallet-gate deadline hit inside a batch renders as that 500 rather than the gate's 503. The batcher is fenced by the engine lease's epoch: the leader bumps the epoch on every arm and every tick reads it under lock, so a deposed leader's next tick aborts and its batcher refuses everything until the process re-arms; a lease never claimed through MySQL has no epoch row and the batcher commits unfenced, logged once as Ledger fence NOT installed. The kill switch and a settings bus that cannot deliver one read as off without a restart. A hold handed an external transaction, and every add (a refund, a release, a fee credit) outside all, stays on the verb. Keep innodb_flush_log_at_trx_commit=1 while this is on: the tick's commit is what the placement was told happened. The batcher's tick figures are on the engine health route under ledgerBatcher. Read on every hold and every fill.
ECO_LEDGER_TICK_MStype: numberdefault: 20
Minimum tick of the ledger batcher, in milliseconds. A tick starts no sooner than this after the previous tick started and never before the previous commit has returned, so under load the tick is the commit's own duration and this value only spaces the idle ones; an idle batcher starts a tick at once for the first operation it receives. Malformed or non-positive reads as the default. Read when the batcher is first built in the process, so a change needs a restart.

The shard tier

Everything in this block is off on a stock install, and an install that sets none of it runs the single matching engine of the sections above. Turning it on is a topology change, not a tuning knob: read Sharding the matching engine before setting any of them, because the ones that place a symbol on a shard have to agree across every process or two engines end up over one order book.

ECO_SHARDStype: numberdefault: 1
How many engine shards the deployment divides its symbols between. Every process must carry the same value — a shard, a door and production.config.js all read it, and the router places a symbol by hashing it modulo this number, so a process that disagrees sends orders to a shard that is not matching them. 1 (the default) is the single engine of today: no shard process, no door, nothing to agree about. Raising it is a planned migration, not a restart: a symbol whose shard changes must have its book drained first, which is what ECO_SHARD_MAP_FILE exists for. Read at boot.
ECO_SHARD_IDtype: numberdefault: 0
Which shard this process is, from 0 to ECO_SHARDS - 1. Set per app by production.config.js (one shard-<id> app per id), never in .env, which would give every shard the same id. A value outside the range, or one that cannot be parsed, refuses the boot with a message naming both numbers rather than reading as 0: a mistyped id used to make a second process silently believe it owned shard 0's symbols, which is two matchers on one book. Read at boot; a shard keeps one id for its life.
ECO_SHARD_PORT_BASEtype: numberdefault: 4300
Loopback port of shard 0; shard N listens on this plus N. The door reaches its shards here, and both sides compute the port with the same function so they cannot drift apart. Like the backend and cron ports this is loopback traffic only — the shard transport carries no authentication of its own, because everything that reaches it has already been through the door's session, key and permission checks. Firewall it and never let it face a network. Read at boot on a shard, and when the door builds each client.
ECO_SHARD_TICK_MStype: numberdefault: 10
How long a shard waits before running a cycle for the symbols marked dirty. One cycle is one ledger-batcher tick, so this is the floor on how long a fill waits before its money commits, and it interacts with ECO_LEDGER_TICK_MS: the effective wait is whichever is longer. A shard with nothing dirty does not tick at all. Read per scheduled tick, so a change needs no restart.
ECO_SHARD_MAP_FILEtype: string
Path to a JSON file that overrides the hash for named symbols, so a market can be pinned to a shard (to drain it before a resharding, or to keep two correlated books on one engine). Shape: {"version": <int>, "shards": <int>, "overrides": {"BTC/USDT": 2}, "signature": "<hmac>"}. A file that is configured and cannot be read, parsed, verified, or whose shards disagrees with ECO_SHARDS stops the process rather than falling back to the hash — a partial fallback is how two shards end up on one book. Unset (the default) is the plain hash, which is what a deployment that never pins a symbol wants. Read at boot.
ECO_SHARD_MAP_SECRETtype: secret
HMAC-SHA256 key the shard map is signed with. A map that carries any override is refused unless this is set and its signature matches the content; a map with no overrides needs no secret. Treat it as a credential: anyone who can sign a map can move a symbol to a different shard, which is why it is the one scale variable the admin diagnostics payload never prints. Not in the template. Read at boot.
ECO_WAL_DIRtype: string
Directory of the write-ahead log. A shard process will not start without it — a shard that cannot make a record durable before it moves money has no recovery story, so it exits rather than run. Each shard owns <dir>/shard-<id> and takes a lock file in it, so two processes cannot write one log. It is also where the ordinary (unsharded) engine keeps its fee journal, which is the one use that works with no shard tier at all: set it and the leader replays a fee that was owed when it crashed instead of dropping it. Put it on the same durable volume class as the database, not on a tmpfs. Read at boot.
ECO_WAL_SNAPSHOT_EVERYtype: numberdefault: 0
Records between write-ahead-log snapshots. A snapshot bounds how much log a restart has to replay; 0 leaves the module's own decision, which is to snapshot only when asked. A snapshot never covers a record whose ledger work is still owed, so raising it costs replay time and never correctness. Read after every cycle.
ECO_POOL_SYNC_MStype: numberdefault: 2000
How stale a market maker's pool balance may get in a shard's in-memory view before the row is re-read. A shard sizes a house fill against this view so it can refuse one the pool cannot cover before the fill record is written — the alternative is a refusal at commit time, which halts the symbol until an operator resolves it. The ledger's own check under SELECT ... FOR UPDATE is still the authority either way, so a longer interval costs accuracy and never money: too high a figure lets a fill through that the ledger then refuses, too low a figure refuses a fill the maker could have funded and it re-quotes. 0 re-reads after every cycle that touched a pool. Read at the end of a cycle.
ECO_RECONCILE_MStype: numberdefault: 300000
How often a leading shard checks itself against the world: its resident order-book levels against the ScyllaDB levels customers read, and the funds held behind its live orders against each wallet's locked balance. It reports and never repairs — a level the projection shows and the shard does not hold is depth nobody can trade against, and a wallet locking less than the orders being matched against it means those orders are live and under-funded, and both are defects to be fixed at their source rather than papered over by a second writer racing the shard. The cost is reads only: one paged book read per resident market and one row per wallet it holds against. Findings appear in the log and in the health payload under shards[].reconcile. 0 turns it off, which turns off the only thing that would notice either condition. Read at boot and on every promotion.
ECO_SHARD_SYMBOLStype: string
Which symbols the shard tier owns, and therefore the only knob that moves one market at a time. Every process must carry the same value — every door, every shard, and the process holding the matching lease — because this is what makes a per-symbol cutover safe: the named symbols are a shard's and the single-process engine stands down from exactly those, so two engines run at once over books that never overlap. Three spellings, and they mean different things: unset (or blank) is the whole venue, which is what the door did before this existed; the literal none is own nothing, which is what the shadow stage needs; a list like BTC/USDT,ETH/USDT is exactly those and nothing else. Order and case do not matter — the list is normalised and sorted so two processes given it differently still agree. Put it in the signed ECO_SHARD_MAP_FILE instead if you want it tamper-evident; a file whose list contradicts this variable refuses to load rather than choosing one. If nothing claims a symbol it rests unmatched — the deliberate direction of the failure, and the matcher logs what it is standing down from at boot. Not ECO_ENGINE_CANARY_SYMBOLS, which confines a flag and has never routed an order. Read at boot; changing it needs a restart, because moving a symbol between owners without a quiesce is how a filled order gets matched twice.
ECO_SHARD_SHADOW_SYMBOLStype: string
Symbols a shadow shard receives a copy of. The single-process engine stays authoritative for them and the shadow matches the same traffic and writes nothing at all — no ledger row, no projection, no fee — so no customer is affected either way. It is the step that earns a real cutover: it runs the shard's real code against this deployment's own order flow rather than against fixtures. A symbol named here and in ECO_SHARD_SYMBOLS refuses the boot, because shadowed and owned are opposites. Set it on the doors (which send the copies) and on the shadow process. Read at boot.
ECO_SHARD_SHADOWtype: booleandefault: false
This shard process is a shadow rather than a real shard: its ledger acks every operation without committing it, it runs no projector and collects no fee, and it leases ECOSYSTEM_SHARD_SHADOW_<id> so it can never hold a real shard down. Its verdict is shards[].health.shadow on the engine health route, where cleanPasses counts consecutive passes on which its book matched the live engine's and resets to zero the moment one does not. Pointless without ECO_SHARD_SHADOW_SYMBOLS, which names what it is copied. Read at boot.
ECO_SHADOW_PORT_BASEtype: number
Loopback port of shadow 0; shadow N listens on this plus N. Its own range so a shadow can run beside a real tier without either fighting for a port. Unset, it is ECO_SHARD_PORT_BASE plus 100, which both the shadow and the door that copies to it compute the same way. Loopback only, exactly as ECO_SHARD_PORT_BASE. Read at boot on the shadow, and when a door builds its shadow client.
ECO_SHADOW_RECONCILE_MStype: numberdefault: 60000
How often a shadow compares its book with the live engine's. Same comparison as ECO_RECONCILE_MS and the same cost — reads only — but pointed at a projection the live engine wrote, so a difference means the two matchers disagreed rather than that a projector dropped a write. A minute rather than five because a shadow is a temporary arrangement someone is watching, and because the comparison is order-insensitive: more passes sharpen the signal instead of adding noise. Falls back to ECO_RECONCILE_MS, then to a minute. Read at boot and on every promotion.
ECO_DOORtype: booleandefault: false
Money-affecting. This process is a door: the order routes forward to the shard that owns the symbol instead of placing locally. Placement, both cancel routes, cancel-all (fanned out across shards and merged) and the Hummingbot order read all take the shard's answer, and a shard's refusal reaches the caller with the shard's own status code and words, so the wire is unchanged. A shard that does not answer is a 503 with Retry-After: 1, and the correlation id the door mints makes that retry safe: the same request cannot hold twice. Off (and while the settings bus is degraded) the local path runs, which on a process that does not hold the lease refuses rather than places — so the fallback can never put two matchers over one book. Requires the shard tier to be running. Read per request.
ECO_PROJECTORtype: booleandefault: false
Project a shard's cycle events (placements, fills, cancels, book levels) into ScyllaDB from inside the shard process, so the order rows and aggregated levels customers read stay current without the old engine writing them. Deliberately classified cost-only rather than money-affecting: its failure mode is a stale projection, never a wrong ledger row, and the degraded-bus rule that forces money-affecting flags off would be the wrong direction for a writer whose absence strands what customers can see. A GET inside the projection's lag window is covered by the door reading through to the shard. Read at boot.
ECO_OUTBOXtype: booleandefault: false
Money-affecting. A cycle's WebSocket frames, candle updates, bot PnL and copy-trading hooks are recorded inside the engine lock and delivered after it releases, instead of being awaited while the lock is held. The frames are the same frames in the same order; what changes is that a slow consumer no longer holds up the next cycle. Off, every broadcast runs inside the lock as it always has, which is also where a degraded bus lands. Read per cycle.
HB_WS_ACTIVITY_GATEtype: booleandefault: false
The two Hummingbot user streams that are built from ORDERS — userOrders and userTrades — skip their per-user database read when that user's orders have not moved since the last one. Both re-read the user's whole order partition (up to 1,000 rows) every second whether or not anything happened, and the frame they then build is almost always suppressed as identical to the last one sent, so the read is the entire cost: at 200 accounts running bots that is 400 thousand-row partition reads a second for accounts that may not have traded all day. The frames do not change. The same builder runs over the same rows with the same filter, sort and cap; only the decision to read moves, so nothing a connector parses is affected. userBalances and userPositions are deliberately NOT gated — a deposit, a transfer or a mark-price move changes those with no order involved. Off by default. Pair it with HB_WS_RECONCILE_MS.
HB_WS_RECONCILE_MStype: numberdefault: 30000
How long a Hummingbot user stream may go without reading for a bot that has had no order activity, while HB_WS_ACTIVITY_GATE is on. This is the staleness bound on everything those frames derive from the CLOCK rather than from an event — the 15-minute recency window that decides which orders appear, and the 200-order cap applied after it — and it is the recovery path for a userTrades frame that back-pressure dropped, since that feed sends deltas rather than snapshots. Lower it to trade reads for freshness; the default is a good balance for a bot that reconciles over REST anyway. Values below 1000 ms are ignored and the default is used, because a floor shorter than the poll interval would mean no gating at all.

The Hummingbot limiter and key cache

Both belong with HB_RATE_LIMIT and HB_AUTH_FAIL_LIMIT under Rate limiting above, and ship with the Hummingbot Connector addon.

HB_LIMITER_FAIL_CLOSEDtype: booleandefault: true
What the signed bot door does when the rate limiter's Redis call fails. true (the default) holds every budget a key has (trade, account, read) to a bucket kept inside the process at a quarter of the configured limit, and reports that smaller cap in X-RateLimit-Limit, so the wire tells the bot the budget it is actually being held to. false, 0, off or no restores the older reading, unlimited while Redis is down. The unsigned market-data endpoints (ticker, order book, trades) keep failing open either way. The local bucket cannot coordinate across processes, so an install with several backend processes may still grant up to a quarter of the limit per process during an outage. Read on every request.
HB_APIKEY_CACHE_TTL_MStype: numberdefault: 0
How long, in milliseconds, a process keeps the API-key row it has just verified, so a bot's next signed request skips the database read. A row is dropped by the TTL, and immediately when the key is edited, disabled, enabled, rotated or deleted through the Hummingbot key routes, which announce the change to every process over Redis (channel hb:apikey:invalidate). A key changed any other way, by direct SQL or an admin route that does not announce, stays valid in every process until the TTL. 0 is off: one read per request, as before. Leave it off unless the key lookup shows in the slow-request line. Read on every request; a change to 0 stops the cache at once.

WebSocket ingress

These apply to every WebSocket route the backend registers, not only the market feed. The first four are handed to the socket server when a route is registered, so they need a restart; the last three are read per frame.

WS_MAX_PAYLOAD_BYTEStype: numberdefault: 16384
Largest client frame, in bytes, the socket server accepts; a larger frame closes the socket, as it did before at this same default. Restart to change.
WS_IDLE_TIMEOUT_Stype: numberdefault: 120
Seconds a socket may stay silent before the server closes it. 0 disables. The socket server rounds to four seconds and requires 0 or at least 8; the value is passed through unvalidated. Restart to change.
WS_MAX_BACKPRESSURE_BYTEStype: numberdefault: 65536
Bytes the server will buffer for a client that is not reading before it drops a frame to that client. The same figure is the threshold at which the broker skips snapshot frames (the market feed's book, ticker, candles and recent trades) for that socket before they are even built, so a slow consumer costs nothing but its own freshness; event frames, which carry a fact the client must not miss, are still handed to the server. 0 disables both. Restart to change.
WS_CLOSE_ON_BACKPRESSUREtype: booleandefault: false
Set to 1, true, yes or on to close a socket the moment a frame is dropped at the backpressure limit, instead of leaving it open with a gap in its stream. Restart to change.
WS_MSGS_PER_SECtype: numberdefault: 0
Inbound frames one socket may send per second, counted before the frame is decoded, keepalives included. The frame past the budget is not handed to the route and the socket is closed with code 1008, message budget exceeded. 0 is unlimited and leaves the old path untouched. The Hummingbot stream keeps its own budget (HB_WS_MAX_MSGS_PER_SEC, 20) and its own error frame, and this one pre-empts it only when set lower. Read per frame.
WS_MAX_SUBSCRIPTIONStype: numberdefault: 0
Subscriptions one socket may hold. A new subscription at the cap is refused with the existing {type: "subscription", status: "error", message} frame and Subscription limit reached (N per connection); one the socket already holds is never refused, and a second browser tab has its own allowance. 0 is unlimited. The Hummingbot stream keeps HB_WS_MAX_SUBSCRIPTIONS (200) on its own frame shape. Read per frame.
WS_SUBSCRIPTION_INDEXtype: booleandefault: false
Answer "who is subscribed to this key" from an index the broker keeps beside its client registry, instead of walking every socket registered on the route for every broadcast. The frames delivered, their order and their recipients are identical either way — the registry stays the authority, and an index entry it no longer holds is dropped rather than served — so the only thing that changes is what a broadcast costs on a route with many sockets. Worth turning on where one route carries thousands of connections; pointless below a few hundred. Read per broadcast, so a change needs no restart.
WS_MARKET_CACHE_MStype: numberdefault: 0
How long, in milliseconds, the market feed keeps its list of enabled markets for answering a SUBSCRIBE, instead of reading the market row from the database for every subscription. One refresh runs at a time; if it fails, that subscription falls back to the read. A market an administrator disables is honoured on the next refresh. 0 is off: one read per SUBSCRIBE, as before. Read per SUBSCRIBE.

Ledger retention

The two ledger tables, transaction and wallet_audit_log, gain a row for every hold, release, fill leg and fee, and nothing removed one. These four variables drive a scheduled job on the cron process that moves old, finished rows into two archive tables with the same columns and hard-deletes them from the live ones. What moves, what never moves, how to run it by hand and how to put a row back are in Backup and restore. All four are read on every run.

ECO_LEDGER_ARCHIVE_ENABLEDtype: booleandefault: false
Money-affecting: rows leave the live tables. true, 1, on or yes lets the hourly ledgerArchive job run; anything else, and the job returns before it opens the database. On, rows of transaction whose createdAt is older than the cutoff, whose status is COMPLETED, CANCELLED or FAILED, and that no foreign key points at (the fee credits admin_profit references, invoices, gateway payments) are copied to transaction_archive with their wallet_audit_log rows to wallet_audit_log_archive, then deleted from the live tables, one MySQL transaction per batch, with the live row deleted only after the archive has been read back holding it. A PENDING, PROCESSING or FROZEN row never moves. The job's DELETE cost counts against the same INSERT ceiling the order path spends, which is why it runs on the cron process on an hourly period rather than continuously.
ECO_LEDGER_ARCHIVE_AFTER_DAYStype: numberdefault: 400
The retention window in days. Rows whose createdAt is strictly before now minus this many days are candidates; the day itself keeps its rows. Unparseable or below 1 reads as the default. The manual script accepts --after-days and refuses 0 for the same reason.
ECO_LEDGER_ARCHIVE_BATCHtype: numberdefault: 1000
Transactions per batch and per MySQL transaction; a batch's audit rows travel in the same transaction. A crash between the copy and the delete rolls both back, and a row found in both tables on the next run is completed, never duplicated. Unparseable or below 1 reads as the default.
ECO_LEDGER_ARCHIVE_MAX_PER_RUNtype: numberdefault: 100000
Cap on transactions moved per scheduled run, and separately on audit rows moved by their own age (the rows with no transaction of their own, and the orphans). 0 is unbounded. The manual script defaults to unbounded and takes --max.

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:

Money and wallets

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.

Infrastructure

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.

Auth and policy

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.

Mail

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.

Duplicate naming families

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.

Legacy alias

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_FRONTEND No reader.
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY Commented out. Consumed by Next.js internals if uncommented, never by application code.

NEXT_PUBLIC_GOOGLE_ANALYTICS_ID, NEXT_PUBLIC_FACEBOOK_PIXEL_ID and the googleAnalyticsStatus / facebookPixelStatus switches used to be on this list. They have been removed from the template and from Settings: no analytics or pixel script is loaded anywhere in the product, so there was nothing for them to switch on.