Processes and ports
Every process a production install runs, which port it binds, how it is started and restarted, and what stops working when it is down.
A production install is three PM2 apps — backend, frontend and cron — plus
four things PM2 does not manage: MySQL, Redis, your reverse proxy, and (only if you
run ecosystem or futures trading) ScyllaDB.
The three apps are defined in one file, production.config.js at the project root.
pnpm start brings up all three. There is nothing in .env to switch between them.
Port map
| Port | Bound by | Carries | Reachable from |
|---|---|---|---|
| 80 / 443 | your reverse proxy | everything the public sees | the internet |
| 3000 | frontend (next start) |
pages, /_next/*, /uploads/* |
proxy only |
| 4000 | backend |
every /api/* route and every WebSocket |
proxy only |
| 4001 | cron |
nothing — the bind exists only to avoid a port clash | nothing |
| 3306 | MySQL | all relational data | backend only |
| 6379 | Redis | sessions, locks, rate limits, the job queue | backend only |
| 9042 | ScyllaDB | ecosystem and futures order books and candles | backend only |
In production the backend calls listen(port) with no host argument, which binds
every interface, not loopback. The installer's firewall step opens ssh, http,
https and 3000, and never touches 4000. So on a stock install the API is directly
reachable on :4000, bypassing the proxy, its rate limits and its TLS.
Close 4000 and 4001 at the firewall, and close 3000 too once your proxy is in front of it.
The three apps
backend
Runs ./backend/dist/index.js. Binds NEXT_PUBLIC_BACKEND_PORT from .env,
default 4000. PORT is set in the PM2 config but the backend ignores it entirely —
it is kept only because other tooling reads it.
This is the whole API: every REST route under /api, all 28 WebSocket endpoints
(they live under /api too, so they ride the same port), and static delivery of
/uploads/*. It also holds the matching-engine lease, so the process that accepts
an order is the process that matches it.
It runs with CRON_MODE=off and registers no scheduled jobs.
If it is down: nothing works. Logins fail, trading stops, every live price feed
goes dead. Server-rendered pages fail too — Next fetches from http://127.0.0.1:4000
during rendering, so a dead backend is not merely a dead API, it is a dead site.
frontend
Runs ./frontend/node_modules/next/dist/bin/next start with cwd set to
./frontend. Serves pages, the /_next/* build output, and the files under
frontend/public/uploads/.
Its port is hardcoded to 3000 in the PM2 config. NEXT_PUBLIC_FRONTEND_PORT in
your .env does not move it: a PM2 env block overrides the inherited environment,
so next start sees 3000 whatever the file says.
The /api and /uploads rewrites in next.config.js are behind a development-only
early return. In production Next forwards nothing. Your proxy must route /api
to port 4000 itself, or the entire API is unreachable while the site looks fine.
If it is down: visitors get a refused connection on the whole domain, even
though the API is healthy. Anything calling /api directly keeps working.
cron
Runs the same entry point as backend — ./backend/dist/index.js — with
CRON_MODE=only and PORT=4001.
Every BullMQ worker this platform registers runs in the process that created it:
await job.handler() executes on the same event loop that would otherwise be
serving HTTP. A job that grows the heap therefore stalls request serving through
garbage collection, and a mark-compact pause near the heap limit was measured at
1.9 seconds with the site answering nothing. Splitting cron into its own process
gives it its own V8 heap, so a badly behaved job takes down the scheduler and
nothing else. max_memory_restart is set to 2G on this app for exactly that reason.
On a cron process the application route surface is never registered. Only / and
/api answer. Do not point a load balancer, a health check or a proxy at 4001.
If it is down: the site looks completely healthy and nothing scheduled runs.
No withdrawal processing, no price updates, no settlement, no deposit expiry. There
is no banner in the admin panel for this. The check is pm2 list — if cron is
missing or stopped, the scheduler is not running anywhere.
maintenance
Not part of a running deployment. It exists only between pnpm stop and
pnpm start, and it binds the frontend port and the backend port so that callers
get a 503 rather than a refused connection. It deliberately does not bind 4001.
It answers 503 JSON to anything under /api/, 503 HTML from maintenance/index.html
to everything else, and sets Retry-After: 300 on both. /health and /api/health
return {"status":"maintenance"} — the same URL the running backend app serves as its
real health probe, so a monitor should read the body to tell a planned window apart from
an outage. See Monitoring.
Services the platform depends on but never starts
installer.sh prompts for the database name, user, password, host and port and
imports initial.sql into an existing server. It never installs mysql-server or
mariadb-server. A reachable database is an unstated prerequisite.
Note also that the installer's connection test is broken — it captures the mysql
output into a local variable and then checks $?, which reports the exit status of
the assignment and is therefore always 0. It will report a successful connection
even when there is none. Verify by hand with mysql -h HOST -u USER -p before
running it.
The backend probes Redis before it touches anything else and exits 78 if it is unreachable, printing install instructions. There is no in-memory fallback any more; the one that existed could not coordinate across processes, so two processes would each believe they held the same lock.
Redis holds sessions, CSRF tokens, rate-limit counters, distributed locks, the BullMQ job queue, cross-process settings invalidation, and the WebSocket relay that lets a cron-originated broadcast reach a browser connected to the web process.
Defaults to 127.0.0.1:6379, database 0. Configured with REDIS_HOST, REDIS_PORT,
REDIS_PASSWORD and REDIS_DB.
Contact points default to 127.0.0.1:9042, data centre datacenter1, keyspaces
trading and futures (SCYLLA_KEYSPACE / SCYLLA_FUTURES_KEYSPACE). It stores
orders, candles, order books and trades for the on-chain exchange.
The installer does not install it and .env.example does not mention it. When it is
unreachable, ecosystem trading endpoints return 503 and the rest of the platform is
unaffected. Set SCYLLA_ENABLED=false to skip it deliberately rather than fail into
that state.
TLS terminates at the proxy — the backend serves plain HTTP and has no certificate
handling at all. In production the auth cookies are issued Secure with
SameSite=None, and browsers discard those over plain HTTP, so an install served
over HTTP cannot log anyone in. HTTPS is not optional.
The installer writes no vhost. If nginx or Apache is already active it restarts the
service and, for Apache, enables proxy, proxy_http, proxy_wstunnel, ssl and
rewrite. The site configuration is yours to write.
Which start command to use
Three PM2 configs ship. Almost every install wants the first.
| Command | Config | Apps | Scheduler |
|---|---|---|---|
pnpm start |
production.config.js |
backend, frontend, cron | its own process |
pnpm start:thread |
production.thread.config.js |
backend (threaded), frontend | inline, on the request loop |
pnpm start:backend |
production.backend.config.js |
backend only | inline, on the request loop |
pnpm start is the supported shape. Use it unless you have a specific reason
not to.
pnpm start:thread replaces the backend entry with backend/dist/thread.js.
The main thread binds 4000 and spawns min(NEXT_PUBLIC_BACKEND_THREADS, CPU count)
worker threads — default 2 — on ports 4001, 4002 and upward. Reach for it only when
request handling itself is CPU-bound.
Two things people expect from this config that it does not do.
It does not isolate cron. Job registration is main-thread-only, so every job still runs on the thread that accepts connections, and worker threads share one V8 heap limit with the process — a leaking job still exhausts the memory every thread depends on. The default deployment already solves this properly, with a separate process.
Worker ports start at 4001, which is the port the cron app pins. Running both
shapes at once means one of them fails to bind. If you want a threaded web tier
and a dedicated scheduler, set CRON_MODE=off in the thread config and move the
worker range, or accept that the threaded config schedules inline — it sets no
CRON_MODE, so a bare pnpm start:thread registers every job on the main thread.
pnpm start:backend is for an API-only host with no frontend tree beside it.
It schedules inline because there is no second process on such a host to do it.
It is not the update path — pnpm updator used to start it and could not work,
because the maintenance server already holds that port.
pnpm start:cron and production.cron.config.js are retired. The file defines
no apps, prints an explanation and exits non-zero, so an operator following an old
runbook gets a message instead of a second scheduler.
CRON_MODE
One variable decides which process registers scheduled jobs. Both halves of the
decision live in production.config.js so they cannot disagree.
| Value | Effect | Set by |
|---|---|---|
unset or inline |
registers jobs and serves HTTP | nothing, by default |
off |
registers no jobs, serves HTTP | the backend app |
only |
registers jobs, serves no traffic | the cron app |
An unrecognised value ("none", "disabled", "true") falls back to inline and logs
an error. It never silently resolves to off, because that would stop every
scheduled job on a live install while the process looked perfectly healthy.
The single escape hatch is CRON_MODE=inline in .env. production.config.js
reads it and drops the cron app entirely, so you end up in one whole state rather
than a web process that schedules nothing next to a scheduler nobody started.
BullMQ hands a repeatable job to whichever worker takes it, and the single-flight guard is per-process — it coordinates nothing across processes. Two processes registering jobs means every scheduled job runs twice over the same rows, withdrawals and settlement included.
pnpm start guards against this in two ways. reconcile-scheduler.js compares the
scheduling role of each running app against the config about to start and deletes
any that disagree, because PM2 skips an app already in its list and keeps the
environment it was first created with. Then it scans for backend processes running
outside PM2 — started by hand, or left by a crash loop PM2 gave up on — and prints
their PIDs. It will not kill them: it cannot tell your tooling from a leftover, and
killing a backend mid-withdrawal is its own damage.
Starting, stopping, restarting
pnpm start # maintenance off, reconcile the scheduler, start all three apps
pnpm stop # stop all three, prove the ports are free, show a 503 page
pnpm restart # stop then start — visitors see the maintenance page in betweenpnpm stop:all # remove backend, frontend, cron AND maintenance from PM2
# ports end up closed, not answering 503pnpm maintenance:start
pnpm maintenance:stoppnpm stop is the stop half of every deployment — pnpm restart and pnpm updator
both go through it — so it proves the platform is down rather than assuming it.
It stops and deletes the three apps, waits up to 15 seconds for ports 3000 and the
backend port to stop accepting connections, scans for backends outside PM2, and only
then starts the maintenance server. If a port is still listening or a foreign backend
is found it exits 1 and refuses to continue, so an update cannot run against a
live database.
Apps are deleted rather than stopped, deliberately. PM2 keeps a stopped app's
original environment and reuses it on the next start, which is exactly how a stale
CRON_MODE survives a config change.
Working with PM2 directly
These are safe and are what you want most of the time:
pm2 list # is cron actually running?
pm2 logs backend --lines 200
pm2 logs cron
pm2 restart backend # restart one app without a maintenance window
pm2 restart cron
pm2 monit
pm2 flush # truncate the log filesThree things that do not behave the way they look:
- A restart does not re-read the config file. PM2 keeps the environment an app
was first created with, and
--update-envre-reads what PM2 stored, not the file. After editingproduction.config.js, or after movingNEXT_PUBLIC_BACKEND_PORTin.env, usepnpm restart— it deletes the apps so PM2 recreates them from the file. Ordinary.envchanges that the PM2 config does not pin are picked up by a plainpm2 restart, because the backend reads.envat boot. pm2 start production.cron.config.jsstarts nothing. See above.pm2 stop allis not yours to run. It stops every app in the daemon, including ones this product knows nothing about.pnpm stop:alltouches only the four names this product owns.
Changing the site URL is a special case: NEXT_PUBLIC_SITE_URL is inlined into
the client bundle and baked into the image allowlist at build time. Editing .env
and restarting is not enough — the browser will keep calling the old origin and
next/image will reject images on the new host. Run pnpm build:frontend first.
Surviving a reboot
There is no systemd unit. The installer deletes /etc/systemd/system/bicrypto.service
if it finds one, because the unit it used to write combined Type=simple with
Restart=always and therefore re-ran pnpm start every ten seconds, restarting the
whole platform each time.
It runs pm2 startup, which installs the boot hook, but it never saves the process
list — so after a reboot PM2 comes up with nothing to resurrect and the platform
stays down.
Run this once, after pnpm start has brought everything up:
pm2 saveRelated: the installation summary prints systemctl start|stop|restart|status bicrypto.
Those commands are wrong — the unit was deleted by the same script that printed them.
Use pnpm start, pnpm stop and pnpm restart.
Exit code 78
The backend and cron apps set stop_exit_codes: [78], so when a process exits 78 PM2
stops it rather than restart-looping. That is deliberate: 78 is raised only for
misconfigurations a restart cannot fix, and sixteen restarts would scroll the one
useful message off the screen.
Three causes:
- Wrong Node major. The pinned uWebSockets.js build ships prebuilt binaries for
Node 22, 24 and 26 only. On anything else the module fails to load four frames
deep and reads like a corrupt install. Supported range is
22 || 24 || 26; the installer targets 26. - Redis unreachable at boot.
- The maintenance server could not bind a port — which means the app that owns that port was not actually stopped.
If pm2 list shows an app as stopped immediately after a start, read
pm2 logs <app> --lines 50 before doing anything else. The reason is printed in
full.
Checking a deployment
-
All three apps online.
pm2 listYou want
backend,frontendandcronallonline. A missingcronmeans nothing scheduled is running, and nothing else will tell you. -
The API answers.
/api/settingsneeds no authentication and is what the update tooling itself uses as a readiness probe.curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4000/api/settings -
The frontend answers.
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/ -
Nothing is listening publicly on 4000 or 4001. From another machine:
curl -m 5 http://YOUR_SERVER_IP:4000/api/settingsA timeout or refused connection is the correct result. A JSON body means the firewall rule is missing.
-
Redis answers.
redis-cli ping