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.

16 min readUpdated 6 September 2026

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

  1. Creates a dump named YYYY_MM_DD_HH_mm_ss.sql in backup/
  2. 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.

POST/api/admin/system/database/backuppermission: access.database
Writes a dump of DB_NAME to disk
GET/api/admin/system/database/backuppermission: access.database
Lists the dumps already on disk
POST/api/admin/system/database/restorepermission: access.database
Drops the database and replays a dump

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 as utf8mb4. Four-byte characters — emoji in support tickets, chat and display names — do not round-trip cleanly.
  • One INSERT per row. Dumps are several times larger and restores several times slower than mysqldump from the shell.
  • It keeps the last 10 dumps. Every backup run prunes backup/ down to DB_BACKUP_RETAIN, default 10, and DB_BACKUP_RETAIN=0 falls back to 10 rather than switching pruning off. Pruning matches only the generated YYYY_MM_DD_HH_mm_ss.sql name 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 outside backup/. 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_NAME and replays that dump. The recreated database is created without a charset clause, so it takes the server default rather than utf8mb4.

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.gz
tar 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 updator runs 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_profit points at every fee credit's transaction with a cascading foreign key, invoice and transaction_ledger_applied cascade 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 how admin_profit itself 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.json

The 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

  1. Stop the platform properly. From the project root:

    pnpm stop

    This stops the backend, frontend and cron apps, 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 with Retry-After: 300 on every route. Restoring into a live database is how you get a half-applied dump.

  2. 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.

  3. Restore ScyllaDB from the snapshot taken at the same time, if you run Ecosystem or Futures. If you cannot, set SCYLLA_ENABLED=false in .env before starting — ecosystem trading routes then answer 503 instead of failing in unpredictable ways, and the rest of the platform serves normally.

  4. Restore the files and .env. Unpack frontend/public/uploads and put .env back. If you are rebuilding on a fresh box, .env must be the one that matches this database — a different ENCRYPTED_ENCRYPTION_KEY cannot read its wallets.

  5. 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 start

    Remove DB_SYNC=always once the platform is up. Leaving it on makes every boot run a full ALTER sweep over the whole schema.

  6. Re-activate licences if the hardware changed. lic/*.lic files are bound to the machine that activated them. On new hardware, go to /admin/system/license and re-enter the purchase code for each product.

  7. Rebuild the frontend if the domain changed. NEXT_PUBLIC_SITE_URL is baked into the client bundle and into the next/image allowlist at build time. Restoring onto a new hostname without pnpm build:frontend leaves 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.

  1. The API answers. curl -sf https://your-domain/api/settings should return JSON. This is the same readiness probe the updater uses, and it needs the backend, MySQL and Redis all healthy.

  2. PM2 shows three apps online. pm2 list should show backend, frontend and cron — or two, if you run CRON_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 in pm2 logs.

  3. 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.

  4. Wallet balances match the source. Pick three users with non-zero balances and compare against the system you restored from. Check inOrder too — a mismatch there is the MySQL-and-Scylla split showing up.

  5. 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, permission manage.ecosystem.kms). Locked and no passphrase means no master-wallet or custodial-wallet operation can run — deposits will be seen and never credited.

  6. 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.

  7. Uploads resolve. Open a KYC application with a document attached. A broken image means frontend/public/uploads was not restored, and KYC review is blocked until it is.

  8. The order books are populated, if you run Ecosystem or Futures. An empty book with users holding inOrder balances means Scylla did not come back. Stop and fix that before you accept traffic — the alternative is manually releasing every reservation later.

  9. 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.

  10. Scheduled tasks are running. /admin/system/cron should 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.