Moving an install to a new server
The order of operations for moving a Bicrypto install to new hardware — what to copy verbatim, what must never be copied, what Redis and ScyllaDB lose, and how to verify before DNS cutover.
Operators do this routinely — a VPS is outgrown, a host is changed, a disaster
recovery drill is run for real. Most of it is an ordinary file and database copy.
Two parts are not, and both are irreversible if you get them wrong: the .env
encryption keys, and the DNS cutover.
The wallet encryption keys. ENCRYPTED_ENCRYPTION_KEY and
ENCRYPTION_KEY_PASSPHRASE live only in .env — they are not in .env.example,
and nothing else on the server can derive them. Restore the database onto a box
with a different pair and every custodial and master wallet the platform ever
generated becomes an address you can watch and never spend from.
The cutover. Once DNS points at the new box, deposits, withdrawals and trades land there. Everything written on the old box after your dump is stranded. Verify the new host completely before you move DNS.
This page is the order of operations. The mechanics of dumping and restoring MySQL are in Backup and restore; the mechanics of the licence are in Licences and activation.
Before the day
-
Lower the DNS TTL. Drop the A record's TTL to 300 seconds at least 48 hours ahead — a record served at 24 hours cannot be pulled back inside a maintenance window, and the rollback below depends on being able to.
-
Build the new box to the same spec. Same Node major (22, 24 or 26 — the range is fixed by the prebuilt WebSocket binaries), MySQL, Redis, and a reverse proxy. See Server requirements.
-
Take a throwaway rehearsal copy if you can. The checks that fail are almost never the database.
-
Collect the purchase codes for the core product and every installed addon. You will re-activate all of them on the new host, and you cannot do it from the panel until the platform is running there.
-
Pick a quiet window. Redis is not migrated (see below), so every user is logged out the moment you cut over.
The order of operations
The first two steps must happen in this order. A mysqldump taken while a
withdrawal is mid-broadcast captures a database that disagrees with the chain.
-
Drain and stop the old host.
node backend/scripts/graceful-stop.mjsThis polls the
transactiontable for withdrawals inPENDINGorPROCESSINGand waits for the count to reach zero before entering maintenance mode. The cap isGRACEFUL_STOP_TIMEOUT_MS, default 120 000 ms; when the count is not observable it falls back to a 10-second grace window. It then runspnpm stopitself, which proves the ports are free and parks a 503 page on 3000 and the backend port. Do not runpnpm stopafterwards. -
Dump MySQL, from the shell rather than the admin screen — the built-in backup takes no lock, ignores
DB_PORTand connects as three-byteutf8:mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p \ --single-transaction --quick --routines --triggers --events \ --default-character-set=utf8mb4 \ "$DB_NAME" | gzip > /var/backups/bicrypto-move.sql.gz -
Snapshot ScyllaDB, if Ecosystem or Futures is live.
nodetool snapshot tradingandnodetool snapshot futures— see below. -
Copy the tree to the new host, with the exclusions in the next section.
-
Restore MySQL on the new host into a
utf8mb4database, and restore the Scylla keyspaces from the snapshots taken in step 3. -
Fix
.envfor the new host:NEXT_PUBLIC_SITE_URL,DB_HOST,REDIS_HOST,SCYLLA_CONNECT_POINTS, and every callback URL listed below. -
Rebuild and start.
pnpm install pnpm build:frontend pnpm start -
Re-activate every licence on the new hardware.
-
Verify against the checklist further down, reaching the new box by its IP or through a hosts-file entry rather than through DNS.
-
Cut over DNS, then re-issue or move the TLS certificate and watch the first hour.
What to copy, and what never to copy
| Path | Move it? | Why |
|---|---|---|
.env |
Verbatim | Holds the wallet encryption pair, the four token secrets and every gateway credential. Edit the host-specific lines after copying — never start from .env.example. |
frontend/public/uploads/ |
Verbatim | KYC documents, dispute evidence, legal files, avatars, ticket attachments. Gitignored, in no release package, and the only copy that exists. |
backend/ecosystem/wallets/ |
Verbatim | Master wallet exports. |
backend/storage/ |
Verbatim | Hummingbot instance state and logs, P2P attachments, the icon-sync cache. |
| The rest of the project tree | Verbatim | Including backend/dist, which ships prebuilt in every release package and is the production backend. |
lic/ |
No | Machine-bound. See the callout below. |
node_modules/ |
No | Rebuilt by pnpm install, and a copied tree carries symlinks into a pnpm store that does not exist on the new box. |
frontend/.next/ |
No | Rebuilt by pnpm build:frontend, and it must be rebuilt because the domain changed. |
backend/.sync-hash |
Either | It describes the models, not the database. Copying it is fine; deleting it costs one full alter sync. See Schema management. |
| Redis | Cannot be | Nothing in it is a record of anything. See below. |
A reasonable exclusion set for rsync:
rsync -az --delete \
--exclude 'node_modules/' \
--exclude 'frontend/.next/' \
--exclude 'lic/' \
--exclude 'backup/' \
--exclude 'updates/' \
--exclude '.update-backup-*' \
/path/to/v5/ newhost:/path/to/v5/rsync -a does not carry .env if your shell expands the source with a glob —
copy it explicitly and confirm it landed before you go any further.
Each lic/<productId>.lic is an AES-256-GCM envelope whose key is derived partly
from the hardware fingerprint of the machine that wrote it. On different
hardware it does not decrypt. It does not fail over — it fails shut.
Worse, the extension manager's licenseVerified flag is not a stored column —
there is no such column on the extension row. The listing route computes it per
product from a single directory read of lic/, so it is nothing more than an
existence check on the file: is lic/<productId>.lic there? Nothing decrypts it,
so a carried-over .lic shows as verified on the new box while every route
the product owns answers 403. That is the exact shape of "the panel says it is
licensed but the feature is dead".
Leave lic/ behind and re-activate. Set the final NEXT_PUBLIC_SITE_URL in
.env before you activate: activation sends the domain from that value plus
the new host's fingerprint to the licence service, and that is the pair it
records.
Re-activating
Two paths, both documented in Licences and activation.
pnpm activate-product <productId> <purchaseCode> [clientName]The headless script does everything the panel's activate flow does and, unlike
the panel, also flips the product's status row and revalidates the gate in one
pass. From the panel, /admin/system/license activates the core product, and
/admin/system/license?productId=<id> activates one addon.
The product ids to feed it are on Extensions, product ids and version sources — record them from the old host before you tear it down.
What is lost, and what nobody tells you
Redis is not migrated
Sessions, CSRF tokens, rate-limit counters, distributed locks, the BullMQ job queue and the cross-process settings bus all live in Redis. None of it is a record of anything, so there is nothing to migrate — but the consequence is concrete:
Every user is logged out. Their next request has no session, and they sign in again. Expect a support spike sized to your active user count, and do the cutover in a quiet window. Two-factor is unaffected; sessions are not.
Do not try to copy an RDB file across. The backend exits with code 78 if Redis is unreachable at boot, so a running Redis on the new host is required — an identical one is not.
ScyllaDB has to be moved separately
If Ecosystem or Futures markets are live, the order books, candles, trade tape, open-order index and futures positions are in ScyllaDB, not MySQL. Nothing in the product backs it up and neither backup screen knows it exists.
nodetool snapshot trading
nodetool snapshot futuresThe keyspace names come from SCYLLA_KEYSPACE and SCYLLA_FUTURES_KEYSPACE
(defaults trading and futures); the contact points come from
SCYLLA_CONNECT_POINTS, which you must repoint at the new cluster. Take the
snapshot and the MySQL dump at the same moment: wallet balances including
inOrder live in MySQL, so a mismatched pair leaves users with funds reserved
against orders that no longer exist.
If you are deliberately not bringing Scylla, set SCYLLA_ENABLED=false in the
new .env. Ecosystem trading routes then answer 503 and the rest of the
platform serves normally, instead of failing in less predictable ways.
Rebuild rather than copy
pnpm install # rebuilds node_modules against this box's store
pnpm build:frontend # mandatory — see below
pnpm startpnpm build:frontend is not optional and not skippable to save time. Every
NEXT_PUBLIC_* value is inlined into the browser bundle at build time, and
NEXT_PUBLIC_SITE_URL is both the origin every client-side API call uses and the
hostname in the next/image allowlist. A tree copied from the old host and
started without a rebuild serves browsers that call the old domain and images
that 400 on the new one.
pnpm build:backend is normally unnecessary: release packages ship
backend/dist prebuilt, which is why the update chain never runs it. Run it only
if backend/dist is missing or you deliberately excluded it from the copy — and
only where the backend source tree is present.
Everything that names the old address
NEXT_PUBLIC_SITE_URL is the one people remember. These are the ones that break
a week later.
In .env on the new host:
| Key | What it feeds |
|---|---|
NEXT_PUBLIC_SITE_URL |
Client API origin, the next/image allowlist, and in production the entire CORS allowlist — the backend derives its http/https and www variants from this value and nothing else. Needs a frontend rebuild. |
APP_PUBLIC_URL |
The base several deposit gateways use to build return and webhook URLs. |
FRONTEND_URL |
PayU and Authorize.Net build customer return URLs from it. Unset, the customer is sent to a literal undefined/finance/deposit?.... |
APP_PAYSTACK_RETURN_URL · APP_PAYSTACK_WEBHOOK_ENDPOINT |
Paystack. |
APP_MOLLIE_RETURN_URL · APP_MOLLIE_WEBHOOK_ENDPOINT |
Mollie. |
APP_PAYFAST_RETURN_URL · APP_PAYFAST_CANCEL_URL · APP_PAYFAST_NOTIFY_URL |
PayFast. |
APP_PAYSAFE_RETURN_URL · APP_PAYSAFE_WEBHOOK_ENDPOINT |
Paysafe. |
APP_PAYTM_CALLBACK_URL · APP_PAYTM_WEBHOOK_ENDPOINT |
Paytm. |
APP_PAYU_WEBHOOK_ENDPOINT |
PayU. |
Outside the box entirely — nothing on your server can change these, and every one of them is a URL you registered in somebody else's dashboard:
- The webhook endpoint for every gateway you enabled: Stripe, PayPal, Adyen, Klarna, Authorize.Net, TransFi and the rest. A webhook still pointed at the old host is a deposit that is paid and never credited.
- Google OAuth authorised origins and redirect URIs, for the client ID in
NEXT_PUBLIC_GOOGLE_CLIENT_ID. - Any exchange or provider API key restricted by IP.
- The reverse-proxy vhost and the TLS certificate, which are yours to write on the new box — the installer writes neither. See Nginx, Apache and Domains and SSL.
In production the auth cookies are issued Secure with SameSite=None, and
browsers discard those over plain HTTP. A new host reachable only over HTTP looks
healthy and cannot sign anyone in. Get the certificate working before you judge
the login.
Surviving a reboot on the new box
There is no systemd unit. The installer deliberately deletes
/etc/systemd/system/bicrypto.service if it finds one — the unit it used to
write combined Type=simple with Restart=always and re-ran pnpm start every
ten seconds. Its closing summary still prints systemctl … bicrypto; ignore it.
pm2 startup # installs the boot hook (the installer does run this)
pnpm start
pm2 save # the installer NEVER runs thisWithout pm2 save, PM2 comes back after a reboot with an empty process list and
the platform stays down. See Processes and ports.
Verify before you touch DNS
Reach the new host by IP, or add a hosts-file entry for the real domain on your
own machine so the browser sends the right Host header without moving DNS for
anyone else.
-
Three PM2 apps online.
pm2 listshowsbackend,frontendandcron— two if you runCRON_MODE=inline. A missingcronmeans nothing scheduled runs, and no screen in the panel will tell you. -
The API answers. This is the same readiness probe the update tooling uses, and it needs the backend, MySQL and Redis all healthy.
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4000/api/settings -
The licence reads active.
GET /api/admin/system/license/statusreturnslicenseStatus— you wantactive, notnot_foundorinvalid. Check every addon, not just core. -
Platform health is green. The admin dashboard at
/adminpolls/api/admin/system/health/batch, which probes the real services and returnsoverall.scoreout of 100 with a status ofhealthy,warningorcritical. A critical service costs 40 points on its own, so anything belowhealthynames a service that is genuinely down. -
A custodial wallet decrypts. Open one in the admin panel. If you copied the wrong
.env, "Invalid encryption data or wrong encryption key" appears here and nowhere else — every other screen looks fine. This is the single check that proves the keys came across. -
Uploads resolve. Open a KYC application with a document attached. A broken image means
frontend/public/uploadsdid not make the trip. -
You can log in, over HTTPS, on the new certificate.
-
The order books are populated, if you run Ecosystem or Futures. An empty book while users hold
inOrderbalances means Scylla did not come back. -
Scheduled tasks have recent run times at
/admin/system/cron. -
Nothing is listening publicly on 4000 or 4001. From another machine,
curl -m 5 http://NEW_IP:4000/api/settingsshould time out or be refused. The installer's firewall step never touches 4000, and the backend binds every interface.
Cutting over, and rolling back
Move the DNS record only once every check above passes. Then:
- Leave the old host stopped but intact for at least a week. Do not delete it, do not reuse the IP, and do not let anything restart it — a second backend writing to the same database, or to a database that is now a stale copy, is the worst state available.
- Watch the deposit queue for the first hour. A gateway webhook still pointed at the old address shows up here as a deposit that never leaves pending. See Working the deposit queue.
- Watch
pm2 logs backendfor licence failures. The heartbeat runs every six hours with a 72-hour grace period, so an egress problem to the licence service on the new box surfaces three days later, not today.
If the new host fails validation, roll back by not cutting over — the old
box's tree and database are untouched and pnpm start on it brings the platform
straight back on the old address.
If you have already cut over and something is wrong, the low TTL is what saves you: point DNS back, start the old host, and reconcile by hand whatever landed on the new one in between. That reconciliation is the reason to cut over into a quiet window and to watch the first hour rather than the first day.