Disk layout: uploads, storage and generated artefacts

Every directory a Bicrypto install writes to at runtime, which ones grow without bound, which belong in a backup, which a build regenerates, and a triage order for when the disk fills.

8 min readUpdated 6 August 2026disk, uploads, storage, backups, housekeeping

A running install writes to seven or eight places outside the database, and several of them grow without any retention policy at all. None of them are enumerated together anywhere else: the backup guidance names frontend/public/uploads/ and stops, and the admin backup screen writes somewhere the backup guidance never mentions.

This is the whole list, what each one is, and what to do when the disk fills.

One rule to read the paths by

Several runtime directories are resolved from process.cwd(), and the backend's working directory is not the same in production and development:

  • Under PM2 (pnpm start), the backend and cron apps run with the project root as their working directory.
  • In development, the backend runs from backend/.

So a directory the code writes as storage/hb appears at <project-root>/storage/hb on a production install and at backend/storage/hb on a development checkout. Where that applies, both locations are given below. If you are triaging a live server, look at the project root first.

The directories

Path Holds Grows Backup?
frontend/public/uploads/ Every user and admin upload Unbounded Yes — irreplaceable
backend/storage/ Runtime working state: bot logs, P2P attachments, icon caches Slowly Partly
backup/ The admin screen's mysqldump output Unbounded, nothing prunes it The dumps are the backup
backups/nft/ NFT addon archives written by a scheduled job Bounded by its own retention Optional
lic/ One .lic per activated product Fixed No — re-activate
updates/ Downloaded release ZIPs Transient No
.update-backup-* Pre-extraction snapshot of an update Transient No
data/chart* Cached candle files Slowly No
backend/ecosystem/wallets/ Master wallet exports Fixed Yes
node_modules/, frontend/.next/, backend/dist/ Build output Fixed No
~/.pm2/logs/ Every process's stdout and stderr Unbounded, unrotated No

frontend/public/uploads/

Every upload on the platform lands here, in one subdirectory per category. The category is the dir argument the calling screen passes to POST /api/upload, so the set of directories is whatever screens have been used on your install. A typical one has among others:

avatar/  avatars/  blog/  category/  collectionsbanners/  collectionslogos/
depositMethods/  disputes/  ecosystemTokens/  editor/  faq/  gallery/  icons/
kyc/  legal/  nft/  p2p/  p2ppaymentmethods/  plan/  pool/  product/  project/
settings/  staking/  support/  team/  ticket/  token/  user/

Stored paths are recorded as /uploads/<dir>/<file>, and either process can serve them: Next serves everything under frontend/public/ on port 3000, and the backend intercepts any URL beginning /uploads/ and serves the file itself. Which one answers depends on how your reverse proxy routes /uploads. Both read the same directory, so a missing file is missing from both.

frontend/public/uploads/ is gitignored, is in no release package, and nothing in the product ever copies it. It holds KYC identity documents, P2P dispute evidence, legal files, support ticket attachments and avatars. If you lose it, KYC review is blocked, every open dispute loses its evidence, and nothing will ever restore it for you.

Include it in every backup. See Backup and restore.

The uploads root is <project-root>/frontend/public/uploads in production and ../frontend/public/uploads relative to backend/ in development, and that is hardcoded. POST /api/upload derives it from process.cwd() and NODE_ENV and reads no environment variable at all; the backend's own /uploads/ handler probes the same two layouts (plus a bare public/) to serve them back. There is no setting that moves this directory.

It reads like the override, and it is not one. Exactly one place in the platform reads UPLOAD_DIR: the ecommerce local-download resolver, which uses it as the root it resolves a stored /uploads/... path against. Nothing writes there. Setting it points that one route away from where uploads actually land, so the traversal guard answers 403 — or 404 — for every digital product the platform itself wrote. Leave it unset.

To keep uploads on a separate volume, keep the path and change what is behind it: mount the volume at frontend/public/uploads, or symlink that directory at it.

Nothing prunes this directory. Deleting a record does not always delete its file, so orphans accumulate; there is no built-in sweep and no way to tell an orphan from a live attachment without joining against the database. Treat it as append-only and size the volume accordingly.

backend/storage/

Runtime working directories that are not user content:

Subdirectory Written by Notes
hb/instances/ The Hummingbot supervisor One <instanceId>.log per bot instance, rotated at 5 MB with exactly one .log.1 kept. Bounded per instance, unbounded in the number of instances.
hb/pyshims/ The Hummingbot supervisor Small Python shim files plus a __pycache__. Regenerated.
p2p/attachments/ P2P trade chat Dispute and trade attachments, one directory per trade id. Back this up. P2P_ATTACHMENT_DIR overrides the location.
icon-sync/ The crypto icon sync Cached CoinGecko index, token lists and a missing-icon report. Pure cache — safe to delete, it refetches.

The Hummingbot and chart paths are the process.cwd() cases described above; the icon-sync directory is resolved from the backend package root and is therefore always at backend/storage/icon-sync. The P2P attachment directory probes for both layouts, so it is stable at backend/storage/p2p/attachments either way.

backup/ — where the admin backup screen writes

Writes a mysqldump of DB_NAME into backup/
Lists the dumps already on disk
Drops the database and replays one of those dumps

The screen is at /admin/system/database/backup and has no entry in the admin menu — you reach it by typing the URL. Files land in backup/ at the project root, named YYYY_MM_DD_HH_mm_ss.sql, resolved from process.cwd(). That directory is outside both web roots, so the dumps are not downloadable through the site.

The restore route resolves strictly inside this directory. It rejects any value containing a path separator or .., requires the name to end in .sql, and then resolves it against backup/ and confirms the result is still inside. That is why the restore takes a bare filename, not a path — and why moving your dumps somewhere tidier makes them unrestorable from the panel.

There is no delete endpoint, no retention policy and no schedule. Every press of the button adds an uncompressed dump, and the tool writes one INSERT statement per row, so its files are several times larger than a shell mysqldump of the same database. On an install where someone got into the habit of pressing it before every change, this directory is where the space went.

Prune it by hand, keeping whatever your retention policy says. Take your real backups from the shell, compressed, on another volume.

backups/nft/

Written by the NFT addon's processNFTBackups scheduled job, which reads per-chain schedules from settings rows keyed nft_backup_schedule_<chain> and runs a full or incremental archive when the schedule's nextRun is due.

The path is BACKUP_PATH if set, otherwise backups/nft under the process working directory — so <project-root>/backups/nft under PM2 and backend/backups/nft in development. BACKUP_ENCRYPTION_KEY encrypts the archives; empty means unencrypted. The service prunes its own old local archives oldest-first by modification time, so unlike backup/ this one is bounded — but only while the job is actually running.

If no NFT backup schedule has ever been configured, this directory will not exist.

lic/

One lic/<productId>.lic per activated product — an AES-256-GCM envelope keyed partly on the host's hardware fingerprint. Small and fixed in size, but two properties matter for disk work:

  • It must be writable by the user PM2 runs the backend as. Activation writes atomically into it (temp file, then rename), so a non-writable directory fails activation rather than corrupting anything.
  • Do not back it up and do not restore it onto other hardware. It will not decrypt, and the extension manager's licenseVerified flag is an existence check, so a stale file reads as licensed while every route 403s. Re-activate instead — Licences and activation.

ls lic/ is also the fastest inventory of which products this install has ever activated: each filename is a product id.

updates/ and .update-backup-*

updates/ at the project root is where a release ZIP is streamed before it is extracted. It is deleted on success and on failure, so a ZIP sitting there means a download that neither completed nor cleaned up.

.update-backup-<timestamp> at the project root is the snapshot the extractor takes of every file it is about to overwrite. It is removed on a successful extraction and consumed by the rollback on a failed one, so a leftover directory means an update that ended badly. Delete it only once you have confirmed the platform is healthy on the new version — until then it is the pre-update tree.

Both can be large: a snapshot of an overwritten tree is roughly the size of the files the release touches.

data/chart, data/chart-fx, data/chart-dex

Gzipped candle caches written by the exchange chart builder and by the forex and DEX addons, one subdirectory per symbol. Resolved from the process working directory, so <project-root>/data/... under PM2. Pure cache — deleting them costs a rebuild of the affected charts and nothing else.

backend/ecosystem/wallets/

Master wallet exports, one JSON file per chain. Gitignored and in no release package. Small, static, and belongs in your backup — it is on the "not covered by anything in the product" list in Updating for a reason.

~/.pm2/logs/

Every line the three PM2 apps write to stdout and stderr, as backend-out.log, backend-error.log and the same pair for frontend and cron, in the home directory of the user that owns the PM2 daemon.

Nothing rotates these. There is no log rotation configured in the product and no application log directory — the backend logs to the console and PM2 captures it. On a busy install with LOG_LEVEL=debug these are frequently the largest files on the box.

du -sh ~/.pm2/logs
pm2 flush              # truncate every log file, immediately

pm2 flush is safe and is the fastest space you can recover on a full disk. For a permanent fix, install PM2's log-rotate module or point logrotate at the directory.

What to back up, what a build regenerates, what is safe to prune

Path Verdict
frontend/public/uploads/ Back up. Irreplaceable.
backend/storage/p2p/attachments/ Back up. Dispute evidence.
backend/ecosystem/wallets/ Back up.
.env Back up, separately from the dumps, encrypted. Losing the encryption pair is unrecoverable.
backup/ These are dumps — copy them off the box, then prune the local copies.
backups/nft/ Optional; the addon prunes its own.
lic/ Do not. Re-activate on new hardware.
node_modules/ Regenerated by pnpm install.
frontend/.next/ Regenerated by pnpm build:frontend.
backend/dist/ Ships prebuilt in every release package; rebuilt by pnpm build:backend where the source tree is present. Not a backup target.
backend/.sync-hash, backend/.types-hash Caches. Deleting either costs one slow rebuild.
backend/storage/icon-sync/ Cache. Safe to delete.
data/chart* Cache. Safe to delete.
updates/, .update-backup-* Transient. Safe to delete once the platform is verified healthy.
~/.pm2/logs/ Safe to truncate with pm2 flush.

When a disk fills

Work down this list — it is ordered by how much space it usually returns for how little risk.

  1. See the whole picture first, from the project root:

    df -h .
    du -sh ~/.pm2/logs backup backups updates .update-backup-* \
           data frontend/public/uploads backend/storage \
           node_modules frontend/.next 2>/dev/null | sort -h

    Then drill into whichever line dominates:

    du -sh frontend/public/uploads/* | sort -h | tail -20
  2. Truncate the PM2 logs. Zero risk, and on most full disks this is the answer.

    pm2 flush
  3. Prune backup/. Copy the dumps you are keeping off the box first, then delete the rest. Uncompressed one-INSERT-per-row dumps with no retention policy is the second most common cause.

  4. Delete leftover updates/ ZIPs and any .update-backup-* you have confirmed you no longer need.

  5. Drop the cachesbackend/storage/icon-sync/ and data/chart*. They refetch and rebuild.

  6. Rebuild rather than clean if node_modules or frontend/.next is the problem. Do not delete them on a live install to make room: the frontend serves out of .next and cannot be restarted without it.

  7. Only then look at frontend/public/uploads/. Nothing in it is safe to delete blind. If it is genuinely the problem, move the contents to a larger volume and mount or symlink that volume at the same path — the uploads root is hardcoded and there is no variable to repoint at a new one. Do not start deleting categories.

The update extractor snapshots every file it overwrites before writing, so it needs roughly the size of the release's changed files in free space on top of the release itself. Out of space, extraction fails, the rollback runs and you get "Update extraction failed — rolled back to previous version" — which reads like a broken package. Check df -h before you download an update, not after it fails.