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, and the two ledger archive tables once the archive job runs | 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
- Creates a dump named YYYY_MM_DD_HH_mm_ss.sql in backup/
- The dumps already on disk — a backup run prunes this list back to ten
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:
- 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. - It keeps the last 10 dumps. Every backup run prunes
backup/down toDB_BACKUP_RETAIN, default 10, andDB_BACKUP_RETAIN=0falls back to 10 rather than switching pruning off. Pruning matches only the generatedYYYY_MM_DD_HH_mm_ss.sqlname shape — a file named anything else is never touched, but a dump you copy back in from another install does match that shape and will be pruned, so park restore candidates outsidebackup/. There is no delete endpoint, so pruning only happens when you take a new backup. - There is no schedule. The only trigger is the button.
- The restore is one click and there is no undo. Each row carries a
Restore action; confirming it drops
DB_NAMEand replays that dump. The recreated database is created without a charset clause, so it takes the server default rather thanutf8mb4.
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.
The ledger archive
The two ledger tables, transaction and wallet_audit_log, take a row for
every hold, release, fill leg and fee and never lose one, so on a busy
exchange their growth, not their write rate, is what eventually limits the
database. The archive job moves finished rows out of them into two tables
with the same columns, transaction_archive and wallet_audit_log_archive,
and hard-deletes them from the live tables. It is off until you turn it on
with ECO_LEDGER_ARCHIVE_ENABLED=true; the four variables that drive it are
in the environment reference.
What moves, and what never does
A transaction row moves when all three hold: its createdAt is older than
ECO_LEDGER_ARCHIVE_AFTER_DAYS (400 days by default); its status is
COMPLETED, CANCELLED or FAILED; and no other table points at it with a
foreign key. Its wallet_audit_log rows move with it, in the same database
transaction. Audit rows that have no transaction of their own (a wallet's
creation) and audit rows whose transaction is in neither table move by their
own age, in two smaller passes.
Three kinds of row never move, and the job discovers the second kind from the schema at every run rather than from a list:
- Anything unfinished.
PENDING,PROCESSING,FROZEN, and the statuses nobody has audited against the archive (EXPIRED,REJECTED,REFUNDED,TIMEOUT). A soft-deleted row is still a row on disk and moves by the same rule. - Anything referenced.
admin_profitpoints at every fee credit's transaction with a cascading foreign key,invoiceandtransaction_ledger_appliedcascade too, and the gateway tables set their link to null; deleting such a row would silently delete a profit row or orphan a payment. Those transactions stay live until you decide howadmin_profititself is retained, and on a busy exchange the fee credits are a large share of the table, so the archive alone does not bound its growth. On the clone this was measured on, 6 of 33 old candidates stayed for this reason. - An audit row whose transaction is still live, whatever its age.
When it runs
ledgerArchive is one of the core scheduled jobs, hourly, on the cron
process and never on the trading one. Each run moves up to
ECO_LEDGER_ARCHIVE_MAX_PER_RUN transactions (100,000) in batches of
ECO_LEDGER_ARCHIVE_BATCH (1,000), one MySQL transaction per batch: the copy
is an INSERT ... SELECT that is a no-op for a row the archive already holds,
the job then reads back which ids the archive holds and deletes exactly those
from the live table. A crash between the copy and the delete rolls both back;
a run interrupted anywhere leaves a state the next run completes; a row that
somehow ends up in both tables is completed on the next run, never
duplicated. The delete is a hard delete, deliberately: transaction is a
soft-delete table, and writing deletedAt on a nine-index row would be a
second full write that frees nothing.
The job's cost is the DELETE, which maintains the same indexes an INSERT does and was measured at a quarter to a third of an INSERT per row (24,900 to 40,800 rows a second on one connection at the reference box, rising with the batch size), so its hour counts against the same ceiling the order path spends. A very large first backlog is better worked through with the manual script below, off-peak, than left to the hourly job.
Running it by hand
backend/scripts/ledger-archive.mjs runs the same job body against the same
models, and is a dry run unless told otherwise:
# What would move, and why the rest would not. Writes nothing.
node backend/scripts/ledger-archive.mjs --db "$DB_NAME"
# Move it, in batches of 1,000, with no per-run cap.
node backend/scripts/ledger-archive.mjs --db "$DB_NAME" --apply
# A shorter window, a smaller batch, a cap, and the report as JSON.
node backend/scripts/ledger-archive.mjs --db "$DB_NAME" --apply \
--after-days 200 --batch 500 --max 50000 --json /tmp/archive.jsonThe database name is printed before anything runs. --ensure-tables creates
the two archive tables when they are absent, which the schema sync otherwise
does on the first boot after the update; an install that boots with
DB_SYNC=none needs one or the other before the job can run. --after-days
refuses 0: a row from today is never a candidate.
Backing it up, and reading it as one ledger
A mysqldump of the whole database carries the archive tables, so the
built-in backup screen and the shell recipe above already include them; a
backup taken per table must add transaction_archive and
wallet_audit_log_archive, or the restored platform has balances whose
history stops at the cutoff. The archive tables carry the primary key and a
createdAt index and nothing else, and no code path other than the job and
the conservation runner reads them.
scripts/ledger-conservation.mjs, the check that proves every wallet balance
is the sum of its ledger, reads the live and archive tables as one ledger
whenever both archive tables exist (live UNION ALL archive), so its verdict
is the same before and after an archive run; that equality was checked on the
clone the job was proven on (27 rows moved, the sums unchanged). If only one of
the two archive tables exists it says so and reads the live tables alone, so
a half-created pair fails loudly rather than judging half a ledger.
Putting a row back
There is no restore script, because the archived row is byte for byte the live row and the job refuses to run if the two column lists differ, so the same column list serves both directions. To bring a transaction and its audit trail back into the live tables, with the platform stopped or the job turned off for the duration:
-- The column list is the live table's own: paste SHOW COLUMNS FROM `transaction`.
INSERT INTO `transaction` (<columns>)
SELECT <columns> FROM `transaction_archive` WHERE id IN ('<id>', ...);
INSERT INTO `wallet_audit_log` (<columns>)
SELECT <columns> FROM `wallet_audit_log_archive` WHERE transactionId IN ('<id>', ...);
DELETE FROM `wallet_audit_log_archive` WHERE transactionId IN ('<id>', ...);
DELETE FROM `transaction_archive` WHERE id IN ('<id>', ...);A row put back that is still older than the cutoff and still terminal is a
candidate again and moves on the job's next run, which is the right outcome
once you have finished with it; leave the job off while an investigation
needs the row live. Nothing about a balance depends on where the row sits:
the wallet's balance and inOrder are columns on the wallet, and the
ledger is the history that explains them.
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.