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.

11 min readUpdated 3 August 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 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.

Writes a dump of DB_NAME to disk
Lists the dumps already on disk
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:

  • DB_PORT is ignored. Both the backup and the restore build their connection from DB_HOST, DB_USER, DB_PASSWORD and DB_NAME only, so they always connect on 3306. If your MySQL listens anywhere else, neither works, no matter what .env says.
  • 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.
  • 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.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.

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.