Backup and restore
Everything a Bicrypto install keeps state in — MySQL, ScyllaDB, Redis, the uploads directory and the .env encryption keys — what to copy, how often, how to restore it, and how to prove the restore worked.
A Bicrypto install keeps state in more places than the database. Copying MySQL and nothing else gives you a backup that restores a platform with every balance intact, every custodial wallet permanently unreadable, and every KYC document gone. This page lists all of it.
What holds state
| Store | Holds | Back it up |
|---|---|---|
MySQL (DB_NAME) |
Users, roles, wallets, transactions, deposits, withdrawals, settings, KYC applications, investments, every addon's records | Yes — the primary target |
.env |
The two keys that decrypt every custodial and master wallet, plus the four token secrets | Yes — and losing it is unrecoverable |
frontend/public/uploads/ |
KYC documents, dispute evidence, legal files, avatars, ticket attachments, product images | Yes |
| ScyllaDB | Ecosystem and Futures orders, order books, candles, trades and open positions | Yes, if you run Ecosystem or Futures |
| Redis | Sessions, CSRF tokens, rate-limit counters, locks, job queues, the settings bus | No — see below |
lic/*.lic |
Addon licence files, encrypted and bound to that machine's hardware fingerprint | No — re-activate instead |
Redis is a hard boot dependency — the backend exits with code 78 if it is unreachable — but nothing in it is a record of anything. Losing Redis logs every user out and clears rate-limit counters; it does not lose money. Do not build a restore procedure that depends on Redis contents.
Licence files under lic/ are AES-256-GCM envelopes keyed partly on the host's
hardware fingerprint, so a .lic copied to a different box will not decrypt.
Restoring onto new hardware means re-activating each addon with its purchase
code at /admin/system/license, not restoring the files.
ENCRYPTED_ENCRYPTION_KEY and ENCRYPTION_KEY_PASSPHRASE are the only way to
read ecosystemMasterWallet.data and every custodial wallet's private key.
The database stores those keys encrypted with AES-256-GCM under a key derived
from that passphrase; nothing else on the server can derive it. Lose the pair
and every on-chain wallet the platform ever generated becomes an address you can
watch but never spend from. Neither variable is in .env.example, so a
"restore from the sample file" recovery does not exist.
Store a copy of .env somewhere separate from the database dumps, encrypted,
and confirm you can read it before you need it.
The built-in backup screen
The platform ships a minimal MySQL dump tool at
/admin/system/database/backup, permission access.database. It has no
entry in the admin menu — nothing anywhere in the panel links to it. You reach
it by typing the URL.
Files land in backup/ at the project root, named
YYYY_MM_DD_HH_mm_ss.sql. That directory is outside both web roots, so the
dumps are not downloadable through the site — you fetch them over SSH.
Know its limits before you rely on it:
DB_PORTis ignored. Both the backup and the restore build their connection fromDB_HOST,DB_USER,DB_PASSWORDandDB_NAMEonly, so they always connect on 3306. If your MySQL listens anywhere else, neither works, no matter what.envsays.- The dump is not consistent. It takes no lock and runs in no transaction, so on a busy platform a wallet row can be dumped before the transaction row that changed it.
- The connection is
SET NAMES utf8, three-byte, while the application connects asutf8mb4. Four-byte characters — emoji in support tickets, chat and display names — do not round-trip cleanly. - One
INSERTper row. Dumps are several times larger and restores several times slower thanmysqldumpfrom the shell. - Nothing prunes it. There is no delete endpoint and no retention policy, so the directory grows until the disk fills.
- There is no schedule. The only trigger is the button.
- The restore button is unreachable. The confirmation dialog exists in the page, but no row action ever opens it. The endpoint works if you call it directly; the UI cannot.
Treat this screen as a convenience for taking a quick snapshot before a risky change. Your actual backup should run from the shell.
Backing up from the shell
Run these from the project root, as the user that owns the install.
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/db-$(date +%F-%H%M).sql.gztar czf /var/backups/bicrypto/files-$(date +%F).tar.gz \
.env \
frontend/public/uploads# Ecosystem and Futures only. Keyspace names come from SCYLLA_KEYSPACE
# and SCYLLA_FUTURES_KEYSPACE in .env (defaults: trading, futures).
nodetool snapshot trading
nodetool snapshot futures--single-transaction is what the built-in tool is missing: it gives you an
InnoDB-consistent dump without locking the platform. --default-character-set=utf8mb4
is what keeps four-byte characters intact.
Copy the results off the machine. A backup on the same disk as the database survives a bad deploy and nothing else.
Nothing in the installer or the platform enables MySQL binary logging. Without
it, your recovery point is your last dump — every deposit, withdrawal and trade
after it is gone. If you take real money, turn on log_bin in MySQL and back
up the binlogs alongside the dumps.
ScyllaDB
ScyllaDB is only in play if Ecosystem or Futures is installed. It is the sole
store for orders, candles, orderbook, trades,
open_orders_by_market, eco_index_state and stop_orders in the ecosystem
keyspace, and orders, position, orderbook and candles in the futures
keyspace. None of that is mirrored into MySQL.
That split is the trap. Wallet balances live in MySQL, including the inOrder
amount held against open orders. Restore MySQL from Monday and Scylla from
Sunday and you get users whose funds are reserved against orders that no longer
exist, and futures positions with no matching balance.
The platform ships nothing to back Scylla up. Use ScyllaDB's own snapshot tooling, on the same schedule as the MySQL dump, and keep the two pairs together so you always restore a matched set.
How often
There is no correct number, only a loss you are willing to book. Anchor it to what happens between dumps:
- Daily is the floor for an install that takes deposits at all. A day of lost deposits is a day of manual reconciliation against gateway and on-chain records.
- Hourly or better once withdrawals are auto-approved or on-chain deposit monitoring is running, because at that point money moves without an operator in the loop and the database is the only record that it did.
- Always take one before an update.
pnpm updatorruns a schema sync and then seeds; both write to the live database, and neither is reversible. - Always take one before touching Super-Admin settings that change how money moves — withdrawal auto-approval, transfer fees, the withdrawal 2FA keys.
Keep at least one dump from before your current retention window. The failure that needs a backup is often discovered days after it happened.
Restoring
-
Stop the platform properly. From the project root:
pnpm stopThis stops the
backend,frontendandcronapps, proves ports 3000 and 4000 are actually free, refuses to continue if a non-PM2 backend is still holding one, and puts up the maintenance server — 503 withRetry-After: 300on every route. Restoring into a live database is how you get a half-applied dump. -
Restore MySQL. Recreate the schema with the charset the platform expects, then replay the dump:
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p -e \ "DROP DATABASE IF EXISTS \`$DB_NAME\`; \ CREATE DATABASE \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" gunzip -c /var/backups/bicrypto/db-2026-08-02-0300.sql.gz \ | mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME"The admin restore endpoint does the same drop-and-recreate, but it omits the charset clause, so the database picks up the server default instead of
utf8mb4. -
Restore ScyllaDB from the snapshot taken at the same time, if you run Ecosystem or Futures. If you cannot, set
SCYLLA_ENABLED=falsein.envbefore starting — ecosystem trading routes then answer 503 instead of failing in unpredictable ways, and the rest of the platform serves normally. -
Restore the files and
.env. Unpackfrontend/public/uploadsand put.envback. If you are rebuilding on a fresh box,.envmust be the one that matches this database — a differentENCRYPTED_ENCRYPTION_KEYcannot read its wallets. -
Reconcile the schema. The backend decides whether to sync by comparing a manifest file,
backend/.sync-hash, against the shipped models. That file describes the code, not the database, so after you swap the database underneath it the default lazy mode will happily conclude nothing changed and skip the columns your dump is missing. Force one full reconcile:DB_SYNC=always pnpm startRemove
DB_SYNC=alwaysonce the platform is up. Leaving it on makes every boot run a fullALTERsweep over the whole schema. -
Re-activate licences if the hardware changed.
lic/*.licfiles are bound to the machine that activated them. On new hardware, go to/admin/system/licenseand re-enter the purchase code for each product. -
Rebuild the frontend if the domain changed.
NEXT_PUBLIC_SITE_URLis baked into the client bundle and into the next/image allowlist at build time. Restoring onto a new hostname withoutpnpm build:frontendleaves browsers calling the old origin and images rejected on the new one.
Verifying the restore
A restore that starts is not a restore that worked. Work down this list before you take the maintenance page off.
-
The API answers.
curl -sf https://your-domain/api/settingsshould return JSON. This is the same readiness probe the updater uses, and it needs the backend, MySQL and Redis all healthy. -
PM2 shows three apps online.
pm2 listshould showbackend,frontendandcron— or two, if you runCRON_MODE=inline. An app that stopped with exit code 78 has a configuration fault, not a crash: unsupported Node major, or unreachable Redis. The message is still inpm2 logs. -
You can log in. This exercises the token secrets from the restored
.env, the cookie flags, and Redis. Everyone was logged out by the restart, so an existing session proving nothing is expected. -
Wallet balances match the source. Pick three users with non-zero balances and compare against the system you restored from. Check
inOrdertoo — a mismatch there is the MySQL-and-Scylla split showing up. -
The ecosystem vault is unlocked. Open the Ecosystem admin overview; it reports vault status directly. Locked means the platform could not decrypt the key from
.env. If the passphrase is deliberately not stored in the file, supply it once per boot through the KMS route (POST /api/admin/ecosystem/kms, permissionmanage.ecosystem.kms). Locked and no passphrase means no master-wallet or custodial-wallet operation can run — deposits will be seen and never credited. -
A custodial wallet decrypts. Open one custodial wallet in the admin panel. If the encryption key is wrong you get "Invalid encryption data or wrong encryption key" here, and only here — every other screen looks fine. This is the single check that proves you restored the matching
.env. -
Uploads resolve. Open a KYC application with a document attached. A broken image means
frontend/public/uploadswas not restored, and KYC review is blocked until it is. -
The order books are populated, if you run Ecosystem or Futures. An empty book with users holding
inOrderbalances means Scylla did not come back. Stop and fix that before you accept traffic — the alternative is manually releasing every reservation later. -
Settings survived. Check that withdrawal approval is still in the mode you expect. Restoring an older database can silently re-enable auto-approval on a platform you had switched to manual review.
-
Scheduled tasks are running.
/admin/system/cronshould show jobs with recent run times. A cron app that never registered leaves deposits unmonitored and payouts unprocessed while everything else looks healthy.
Once all ten pass, bring the platform back with pnpm start if it is not
already up, and confirm the maintenance page is gone.
Practising it
The only backup you know works is one you have restored. Restore into a staging
copy at least once a quarter, and read the whole verification list there — the
checks that fail are almost never the database itself. They are the .env you
copied from the wrong host, the uploads directory nobody included, and the
Scylla snapshot that was taken six hours after the dump.