MMashDiv

Updating

How to apply a release safely — what to back up first, what the update chain actually does, and how to confirm the platform came back up on the new code.

15 min readUpdated 1 October 2026

An update has two halves.

The download fetches a ZIP from the licence service and extracts it over the project root. It replaces files on disk. It does not touch the database, does not reinstall dependencies, does not rebuild the frontend and does not restart anything — the running processes carry on executing the code they loaded at boot.

The apply step makes the new files take effect: dependencies, schema, seed data, frontend build, restart. It is the same chain as pnpm updator.

From Core 6.8.4 the admin panel does both. Update All on Add-ons & Integrations, Install v… on System Updates and Install v… or Update Now on an add-on's own page download every pending release in order and then apply them. The site shows its maintenance page while they are applied, usually for 10 to 20 minutes, and the page you pressed the button on reloads when the site is back.

A panel older than 6.8.4 only downloads. When you take a 6.8.3 or older install to 6.8.4 from the panel, run pnpm updator on the server afterwards, once, or update from the server with pnpm update-all instead. Until then the platform serves the old code from a tree that has already been replaced underneath it. From 6.8.4 onwards the panel applies its own updates.

Back up before you start

The platform ships a MySQL backup and nothing else. Everything in the second list below has to be your own copy.

POST/api/admin/system/database/backuppermission: access.database
Dumps the MySQL database with mysqldump

The screen is at /admin/system/database/backup. It has no menu entry anywhere — type the URL. Dumps land in backup/ under the project root, named for the timestamp (2026_08_03_14_21_09.sql), and the restore screen reads from that same directory. It covers MySQL only.

Not covered by anything in the product:

  • .env — the single most expensive file on the box. See the warning below.
  • frontend/public/uploads/ — KYC documents, dispute evidence, legal files, avatars. It is gitignored and it is in no release package, so nothing will ever restore it for you.
  • lic/ — the encrypted, machine-bound licence files.
  • backend/ecosystem/wallets/ — master wallet exports.
  • Redis — sessions, CSRF tokens, rate limits, locks and job queues. Cheap to lose, but the backend will not boot at all without a reachable server.
  • ScyllaDB — the keyspaces named by SCYLLA_KEYSPACE and SCYLLA_FUTURES_KEYSPACE. Order books, candles, the trade tape and the open-order index for ecosystem and futures trading live there, not in MySQL. The installer never installs Scylla and the backup screen does not know it exists.

ENCRYPTED_ENCRYPTION_KEY and ENCRYPTION_KEY_PASSPHRASE are what decrypt every custodial wallet private key the platform holds. They exist only in your .env and they are not in .env.example, so a fresh copy of that file will not contain them. Restoring a database without them leaves the wallets present and permanently unspendable. Copy .env somewhere off the box before every update.

What survives an update

Path What happens to it
.env Never shipped in a release package — yours survives untouched. Releases do add new keys, so compare it against .env.example afterwards.
frontend/public/uploads/ Untouched. Not in the package, and the extractor never deletes.
lic/ Untouched. Reactivation is only needed if the machine fingerprint changes.
The database Not in the package. Changed in place by the migration and seed steps of the chain.
backend/dist/ Replaced. This directory is the production backend — it ships pre-built, which is why the update chain never runs build:backend.
node_modules/ Rebuilt as far as necessary by the install step.
frontend/.next/ Rebuilt by pnpm build:frontend. Stale until that step runs.

Extraction only ever writes. A file a release removed is still sitting on your disk afterwards, and that is not always harmless: a deleted page.tsx under frontend/app is still a route and can break the next build, and a deleted module that shares a name with a directory beside it wins module resolution over the directory that replaced it. Clear those out after the update:

pnpm clean:stale --check   # preview what would be deleted
pnpm clean:stale           # delete it

The update screen

  1. After the update, this badge should read the version you applied

Admin → System → System Updates, at /admin/system/update.

The page itself is gated on access.system.update, but every button on it calls a route that requires create.license. A role granted only the first sees the screen fully rendered and gets a permission error from the check and from the download. Grant both, or neither.

Without a verified licence the page replaces itself with a purchase-code activation form — there is no update UI at all until activation succeeds. Once licensed, three tabs:

Licence status, current version and whether an update is waiting, plus links to support, documentation and the extension manager.

The check, the changelog for the pending version, and the button that downloads it.

Every published version for this product, selectable from a list. Hidden entirely if the notes could not be fetched.

Releases are installed one version at a time, in order. The licence service returns the full list of pending versions; the panel downloads each in turn, checking again after each one, and applies the result once at the end. Jumping straight to the newest version is not available, and a queue of more than one shows a "Sequential Updates Required" banner with the whole path spelled out.

POST/api/admin/system/update/checkpermission: create.license
Asks the licence service which versions are pending
POST/api/admin/system/update/downloadpermission: create.license
Downloads one version and extracts it over the project root

Extensions, blockchains and exchange providers use the same download route with a type field. Update All on Add-ons & Integrations (/admin/system/extension) opens the "Update everything" dialog and takes core first, then every licensed add-on, blockchain and exchange provider. When core has an update that does not finish, the add-ons are not attempted, because they are built for the new core. The admin dashboard's update widget calls a batch check across every installed product at once.

What happens after the downloads

When a run has downloaded anything, even if another product failed or you pressed Stop after this step, the panel starts the apply step on the server. It runs in the background, outside the platform's own processes, so stopping the backend does not stop it.

POST/api/admin/system/update/finalizepermission: create.license
Starts the apply step: stop, dependencies, schema, seed, build, start
GET/api/admin/system/update/finalizepermission: create.license
Reports the last apply run, its step and its log
  1. Stop. The same graceful stop as pnpm update-all: it waits up to GRACEFUL_STOP_TIMEOUT_MS (default two minutes) while any withdrawal is pending or processing, then runs pnpm stop. A withdrawal waiting for your approval counts, so with one in the queue the stop always waits the full two minutes.
  2. Dependencies, schema, seed data and frontend build, exactly as the links of pnpm updator below.
  3. Start, with pnpm start.

The update dialog lists the steps and ticks them off. While the site is down the maintenance server answers the status request itself, with the state and the step only, so the dialog keeps working. You can close it or leave it open; it reloads the page once the backend answers again.

Only one apply run happens at a time. A second request while one is running, from another tab or another admin, is refused with "The updates are already being applied", and the dialog follows the run already in progress.

Two files under updates/ at the project root record each run:

File What it holds
updates/finalize-status.json The latest run: its state, the step it is on or failed at, and whether the site came back after a failure.
updates/finalize.log The full output of every step, appended run after run. The dialog shows the end of it when a run fails.

If ENCRYPTION_KEY_PASSPHRASE is not in your .env and you unlock the ecosystem wallets with a passphrase in the admin panel, that unlock lasts only until the backend restarts. Enter the passphrase again (Admin → Ecosystem) after every update, or native withdrawals fail until you do.

When the apply step fails

Step that failed What the site does What to do
Stop Keeps running, on the old code. The new files are on disk and load at the next restart. Read the log, then press Try Again, or run pnpm updator on the server.
Dependencies or schema Stays on the maintenance page. Starting the new backend without its modules or its schema would only crash-loop. Read updates/finalize.log on the server, fix the cause, then run pnpm updator.
Seed data Starts on the new release; the schema is already in place. Read the log, then press Try Again.
Frontend build Puts the previous frontend build back and starts on it, so visitors see the previous pages served by the new backend. Read the log, then press Try Again.
Start May be left on the maintenance page, depending on how far pnpm start got. Run pnpm start on the server and read pm2 logs.

Try Again appears in the dialog only while the site is up, because it runs through the backend. A second Update All does not retry: the releases are already downloaded, so it finds nothing to install.

The build step keeps a copy of the previous build as frontend/.next.before-update while it runs and deletes it when the build succeeds.

If the licence service is unreachable, the check is caught and answered with You have the latest version of the product. — the same message as a genuine up-to-date result. A missing or unreadable licence file answers No purchase code found under the same green heading. Read the message line, not the heading.

Where release notes come from

The changelog shown on this screen is not stored on your server. The browser calls a backend proxy, which fetches a JSON bundle from the documentation site:

GET/api/admin/system/patch-notespermission: access.admin
Proxies the published patch notes for every product

That request fails gracefully and silently. If the docs host is unreachable from your server the proxy returns an empty bundle, the Changelog tab disappears and the Updates tab reads "No changelog available for this version" — a network symptom, not a release that shipped without notes. The panel then falls back to whatever changelog string the update-check response happened to carry.

The same notes are published at /docs/releases, so you can read what a version changes before you take the site down for it.

Applying an update

  1. Take the backups. Database dump, plus your own copy of .env, frontend/public/uploads/ and lic/.

  2. Press Update All on Admin → System → Add-ons & Integrations, or Install v… on Admin → System → System Updates for core alone. Every pending version is downloaded and then applied. Pick a quiet time: the site shows its maintenance page for 10 to 20 minutes.

  3. Unlock the ecosystem wallets again if you enter their passphrase in the admin panel rather than keeping it in .env.

  4. Verify, using the checklist further down.

From the server instead, which is also the way to apply 6.8.4 itself on an older install:

pnpm update-all --dry-run   # list what would be applied
pnpm update-all             # download everything, then finalise once

pnpm update-all finishes by running the same chain itself, so do not run pnpm updator after it unless you passed --no-finalize. After downloading from a panel older than 6.8.4, run pnpm updator to apply what it downloaded.

pnpm updator is pnpm stop, then dependencies, then schema, then seed, then frontend build, then pnpm start. Each link stops the chain if it fails, which is deliberate: every one of them exists to prevent the next one running against a half-updated install.

pnpm stop — maintenance mode

Stops and removes the backend, frontend and cron apps from PM2, then proves the frontend port (3000) and the backend port (NEXT_PUBLIC_BACKEND_PORT, default 4000) are actually free and scans for a backend process PM2 does not own. If either check fails it exits non-zero and the update stops before touching anything — a second backend on the same database during a schema migration is far worse than a failed update.

It then puts the maintenance server on those two ports: 503 JSON for anything under /api/, a 503 HTML page for everything else, both with Retry-After: 300. Port 4001 is deliberately left unbound so the migration step can use it.

pnpm stop:all removes the maintenance server too, which closes the ports entirely instead of serving a page — use it only when you want the site to be visibly down.

ensure-deps — dependencies

Runs pnpm install -r --no-frozen-lockfile, then verifies that every declared dependency and every executable the rest of the chain shells out to actually resolves, and repairs the tree if they do not.

This is not belt-and-braces. When a release changes dependency resolution, pnpm rebuilds the root store but leaves backend/node_modules in place, and its links now point at store paths that were just replaced. A dangling link is not an empty directory — it looks present and fails at require() time, so the symptom is the backend dying on Cannot find module 'bullmq', one package per restart, with nothing saying "your install is incomplete".

If it cannot produce a working tree it stops the update rather than let a schema migration run against a backend that cannot boot. The message tells you which of the three usual causes to check: a full disk, something still running and holding files open, or a damaged pnpm store.

updator:migrate — schema

Sequelize applies the schema during boot, so the migration step is a boot. It runs backend/dist/index.js directly — not under PM2, so nothing can restart it behind your back — with the scheduler switched off and boot restricted to the schema phase, on the first free port from NEXT_PUBLIC_BACKEND_PORT + 1 upward (4001–4020 on a default install).

It then waits for GET /api/settings to answer, which is the observable that says initialisation finished, and kills the process. The cap is 180 seconds. A very large schema legitimately needs longer:

node scripts/updator-migrate.js --timeout=600000

If it exits early or never becomes ready, nothing is seeded and the site stays on the maintenance page — which is the correct place to stop, because the alternative is seeders writing through models the database does not match yet.

Schema behaviour is controlled by DB_SYNC in .env: lazy (the default) syncs only what changed, using the fingerprint in backend/.sync-hash; none authenticates without altering anything; always forces a full ALTER sync, which is the escape hatch when the schema has drifted outside Sequelize. force drops and recreates every table and destroys all data — never set it on a live install.

pnpm seed — reference data

Writes the rows the code expects to exist: permissions, roles, deposit gateways, notification templates. It runs through the new models, which is exactly why it comes after the schema step and never before it.

pnpm build:frontend — the rebuild

Rebuilds frontend/.next. This is not optional and it is not skippable to save time.

Every NEXT_PUBLIC_* value is inlined into the browser bundle when the frontend is built. NEXT_PUBLIC_SITE_URL is the origin every client-side API call uses and the hostname in the next/image allowlist. An un-rebuilt frontend serves the previous release's pages against the new backend, and if you also changed the domain, the browser calls the old one and images 400.

The backend needs no equivalent step: backend/dist ships pre-built inside the release package.

pnpm start — back online

Clears maintenance mode and starts the three PM2 apps from production.config.js: backend on NEXT_PUBLIC_BACKEND_PORT, frontend on 3000 (hardcoded — it is not moved by .env), and cron on 4001 with CRON_MODE=only. Nothing should ever connect to 4001; the port exists only so the scheduler process does not collide with the backend.

Setting CRON_MODE=inline in .env drops the cron app entirely and schedules jobs inside the backend process instead, so you will see two apps rather than three.

Updating every product at once

pnpm update-all enumerates the core product plus every installed extension, blockchain and exchange provider, downloads each pending version in order, and then finalises once — graceful-stop, dependencies, schema, seed, frontend build, restart — instead of taking the site down per addon.

pnpm update-all --dry-run       # report only, downloads nothing
pnpm update-all --no-finalize   # download everything, leave the site running old code
pnpm update-all                 # download and finalise

Its graceful stop waits for in-flight withdrawals to quiesce before entering maintenance mode, up to GRACEFUL_STOP_TIMEOUT_MS (default 120 seconds), rather than killing the backend mid-broadcast.

update-all reads backend/dist without booting it, and falls back to the source tree if dist will not even load. That matters when a build crashes on startup: the admin panel's Update button needs a running site, so a backend that dies at boot locks you out of the only UI that could replace it. This script can still fetch its own replacement.

Verifying the update

  • pm2 list — backend, frontend and cron all online (two apps if CRON_MODE=inline). A backend restarting in a loop is the one thing you must not walk away from: pm2 logs backend.
  • Load the site over HTTPS and sign in. Cookies are issued Secure and SameSite=None in production, so a login that fails on every browser usually means TLS terminated somewhere it should not have.
  • Admin → System → System Updates — the version badge should read the version you just applied.
  • pnpm clean:stale --check, then pnpm clean:stale, to remove files the release deleted.
  • Diff .env against .env.example for keys the release added.
  • pnpm check:permission reports permission drift and lists screens waiting on a grant. New permission gates ship strict: a newly gated screen is reachable by Super Admin only until you grant its key per role under Admin → Users → Roles & Permissions.
  • pm2 save, so a server reboot resurrects the apps you just started. The installer runs pm2 startup but never pm2 save, so on many boxes a reboot brings PM2 back with an empty list.

When it goes wrong

The site is stuck on the maintenance page

The chain stopped at one of its links. Read the last error it printed — every failure mode above prints what to check. When the update was started from the admin panel, that output is in updates/finalize.log, and updates/finalize-status.json names the step. Nothing is left half-applied: the schema step will not have seeded, and the extraction step either refuses before it writes anything or rolls itself back.

To bring the site straight back up on whatever code is currently on disk:

pnpm start
"Update refused — nothing was changed"

Before it writes anything, the extractor checks that it can write every directory and every existing file the release will replace. If it cannot, it refuses and names the paths with the errno that said so:

Update refused — nothing was changed. Refusing to extract the update: 37 of
1841 paths it must write cannot be written. NOTHING HAS BEEN CHANGED - the
application is exactly as it was. Blocked: /home/app/backend/dist/src/utils
(EACCES), ... The update runs as app (uid 1001). EACCES, EPERM and EROFS mean
ownership or permissions ...

A refusal is the good outcome: the tree is untouched and there is nothing to roll back. Read the codes:

  • EACCES / EPERM — ownership. Almost always a subtree created by an account an earlier deploy ran as. ls -ln on a named path shows a bare uid with no name when that account no longer exists. chown -R it to the account the platform runs as, then download again.
  • EROFS — the filesystem is mounted read-only.
  • ENOTDIR / EISDIR — something of the wrong kind sits at that path: a file where the release needs a directory, or the reverse.
"N of M files could not be written" / "The update did not fully apply"

The extractor snapshots every file it overwrites into .update-backup-<timestamp> at the project root before writing, and restores the lot if any single file fails. It then re-reads every file it believes it wrote and compares it against what the archive declares — because the zip library reports success for writes it silently declined to make. Either check failing rolls the tree back and names the files.

There is nothing to undo by hand — the tree is the pre-update one. Fix the paths named in the message and download again.

Delete a leftover .update-backup-* directory only after you have confirmed the platform is healthy on the new version.

An update that reports success has been verified on disk. "Update downloaded and applied successfully. 1841 files updated and verified on disk" means every file the archive carries was read back off the filesystem at the size the archive declares. Older versions counted what the zip library claimed to have written, which is not the same thing: a subtree the platform could not write was reported as updated, and the first sign of it was Cannot find module at the next restart.

A process exits with code 78

78 is EX_CONFIG, used by the platform for "this box is not configured to run this". It means one of two things: Redis is unreachable, or Node is on an unsupported major version. Supported majors are 22, 24 and 26 — the range is fixed by the prebuilt WebSocket binaries the backend ships, not by preference. Every PM2 config treats 78 as a stop rather than a crash, so the app will sit there stopped instead of looping.

"Cannot find module" after an update

Usually an incomplete dependency install, not a bad release. Re-run pnpm updator; the install step detects and repairs dangling links. If it recurs on every run, you almost certainly have two different pnpm versions on the machine fighting over the same node_modules — which -a pnpm will show them.

If the module it names is one of ours (utils/… rather than a package), the download did not fully land. Check that the path exists:

ls -ln backend/dist/src/utils/

An empty or stale directory whose owner shows as a bare uid is the case the refusal above now catches before writing. Fix the ownership and download the update again — the extraction will not report success over it.

A screen 404s or the build fails on a file that is not in the release

A leftover from a previous version that the release deleted. Run pnpm clean:stale --check to see it, then pnpm clean:stale to remove it, and rebuild with pnpm build:frontend.