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.

11 min readUpdated 12 August 2026pm2, ports, cron, operations

A production install is three PM2 appsbackend, 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 between
pnpm stop:all     # remove backend, frontend, cron AND maintenance from PM2
                  # ports end up closed, not answering 503
pnpm maintenance:start
pnpm maintenance:stop

pnpm 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 files

Three 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-env re-reads what PM2 stored, not the file. After editing production.config.js, or after moving NEXT_PUBLIC_BACKEND_PORT in .env, use pnpm restart — it deletes the apps so PM2 recreates them from the file. Ordinary .env changes that the PM2 config does not pin are picked up by a plain pm2 restart, because the backend reads .env at boot.
  • pm2 start production.cron.config.js starts nothing. See above.
  • pm2 stop all is not yours to run. It stops every app in the daemon, including ones this product knows nothing about. pnpm stop:all touches 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 save

Related: 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:

  1. 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.
  2. Redis unreachable at boot.
  3. 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

  1. All three apps online.

    pm2 list

    You want backend, frontend and cron all online. A missing cron means nothing scheduled is running, and nothing else will tell you.

  2. The API answers. /api/settings needs 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
  3. The frontend answers.

    curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/
  4. Nothing is listening publicly on 4000 or 4001. From another machine:

    curl -m 5 http://YOUR_SERVER_IP:4000/api/settings

    A timeout or refused connection is the correct result. A JSON body means the firewall rule is missing.

  5. Redis answers.

    redis-cli ping