Migrate and upgrade OpenWA
This guide walks you through the migrations every self-hosted OpenWA operator eventually runs: upgrading to a new version, moving from SQLite to PostgreSQL, switching media storage or Redis, relocating sessions to a new host, and rolling back when something breaks.
Every procedure here is backup-first. The one piece of state you cannot regenerate is session auth — lose it and you re-scan the QR code for every linked WhatsApp number. So you back up first, change one thing at a time, and verify before you delete the old data.
Database rows can be rebuilt; linked-device credentials cannot. Before any upgrade or data
move, back up your database and both session-auth directories (./data/sessions for the
default whatsapp-web.js engine, ./data/baileys for Baileys), whichever engine you run. A
migration that corrupts auth state forces a fresh QR scan on every session.
- A running OpenWA instance you can stop for a short maintenance window. See Deployment.
- An admin API key. The infrastructure endpoints used below (
/api/infra/*) require theADMINrole — a read/send key returns403. See Authentication. - Shell access to the host (or container) and roughly twice your current data size in free disk.
- Every
docker composecommand below targets the repository's defaultdocker-compose.yml. If you run the single-containerdocker compose -f docker-compose.dev.yml up -dstack instead, add-f docker-compose.dev.ymlto each of them and writeopenwawherever a command names theopenwa-apiservice. Do not carry the flag onto the--profile postgrescommands: those services exist only in the production file. The two files also store data differently, a bind-mounted./dataversus the namedopenwa-datavolume, so mixing them points the stack at the wrong data. - This page targets OpenWA v0.23.5. Confirm your running version with
GET /api/healthand your API key.
Pre-migration checklist
Run through this before you touch anything. Each box maps to a recovery path if the migration fails.
- Database backed up — SQLite file copy, or
pg_dumpfor PostgreSQL. - Session auth backed up — both
./data/sessions(whatsapp-web.js) and./data/baileys(Baileys), not just the engine you run: the first boot on v0.23.5 renames every name-keyed auth directory under both to the session id. A rollback to v0.23.4 or earlier looks for the name-keyed directory, finds nothing, and starts those sessions at a QR code unless this backup is restored. - Config saved — your
.envanddocker-compose.yml. - Current version recorded with
GET /api/healthand your API key (theversionfield is returned only to a caller presenting a valid key, v0.19.0+). - Disk space confirmed — about 2x current data size, so a copy and the original coexist.
- Rollback target noted — the release you would return to: a git tag for a source-built
deployment, or the previous image tag for a pinned one (for example
rmyndharis/openwa:0.7.5). - Tested in staging — for major upgrades or a database switch, rehearse on a copy first.
Take a backup
Back up the database, both session-auth directories, and your config. Adjust paths if you changed
the defaults (DATABASE_NAME, SESSION_DATA_PATH, BAILEYS_AUTH_DIR).
mkdir -p ./backups
# 1. Database
# SQLite (default — the data DB lives at ./data/openwa.sqlite):
cp ./data/openwa.sqlite ./backups/openwa-$(date +%Y%m%d).sqlite
# PostgreSQL:
pg_dump "$DATABASE_URL" > ./backups/db-$(date +%Y%m%d).sql
# The main DB is always SQLite and always local. Copy it whichever data backend you run,
# or the backup loses every API key and the whole audit log:
cp ./data/main.sqlite ./backups/main-$(date +%Y%m%d).sqlite
# 2. Session auth: back up BOTH engine directories, whichever engine you run, or a rollback
# past v0.23.5 cannot restore the pairings. Skip either one this install never created.
tar -czf ./backups/sessions-$(date +%Y%m%d).tar.gz ./data/sessions
tar -czf ./backups/baileys-$(date +%Y%m%d).tar.gz ./data/baileys
# 3. Config
cp .env docker-compose.yml ./backups/
OpenWA splits storage into a main database (./data/main.sqlite — API keys and audit logs)
that always stays local, and a data database (sessions, webhooks, messages) that is the
pluggable one you migrate between SQLite and PostgreSQL. Back up both files when on SQLite; the
main DB is never part of the data export below.
The repo's scripts/backup.sh captures both databases, engine auth, local media, plugins, and
bootstrap config in one archive, and scripts/restore.sh puts it back. Both resolve database
paths exactly like the app does — MAIN_DATABASE_NAME / DATABASE_NAME, falling back to the
fixed ./data defaults. backup.sh fails hard on a missing source database, and deletes an
archive that fails its post-write content check instead of reporting success over it. When the
sqlite3 CLI is available (it ships in the production image), the script takes an
online-consistent snapshot via sqlite3 .backup instead of copying a possibly-live file; without
it the archive carries a CONSISTENCY-WARNING marker that restore.sh --strict refuses to
restore. Since v0.11.0 backup.sh exits non-zero where it previously reported success over an
empty or partial archive — expect scheduled jobs pointed at the wrong paths to start alerting,
and treat that as the fix working. Since v0.19.0 restore.sh refuses to restore over a live
database unless --force is passed, and both scripts ship inside the production image. Since
v0.23.0 both scripts follow PLUGIN_STATE_DIR: with the knob set, the archive carries the
plugin registry and each plugin's persisted storage, and a restore puts them back.
Upgrade to a new version
OpenWA ships as a Docker image on Docker Hub (rmyndharis/openwa; also mirrored to
ghcr.io/rmyndharis/openwa). The repository's docker-compose.yml builds that image from source rather than pulling it, so how
you move to the new version depends on which of the two you run. Either way an upgrade is: back up,
move to the new version, restart, verify.
# 1. Back up (see above), then stop the running version
docker compose down
# 2. Move to the new version. The repo's compose file BUILDS the API image from source:
git pull && docker compose up -d --build
# A deployment pinned to a published image instead (rmyndharis/openwa:<version>) bumps the tag
# in its own compose file, then runs: docker compose pull && docker compose up -d
# 3. Verify health and the running version (`version` needs a valid API key)
curl -f http://localhost:2785/api/health -H 'X-API-Key: YOUR_API_KEY'
A healthy instance returns (the version field only appears when the request carries an API key, v0.19.0+):
{ "status": "ok", "timestamp": "2026-06-26T10:00:00.000Z", "version": "0.23.5" }
Run schema migrations
Schema changes are managed by TypeORM migrations, and the data database runs them
automatically at boot: always on PostgreSQL, and on SQLite unless DATABASE_SYNCHRONIZE=true puts
it in synchronize mode instead. The main (auth/audit) database is the other way round: it
defaults to synchronize and runs no migrations until you set MAIN_DATABASE_SYNCHRONIZE=false. An
upgrade therefore needs no migration command of its own. The commands below are for inspecting the
chain, or for running it by hand against a stopped app when a long index build would outlast an
orchestrator's liveness grace. The production image strips the TypeScript toolchain, so use the :prod
script variants, which run the compiled migrations from dist/.
# Run them inside the image: the compiled migrations these scripts point at live in dist/, which
# a cloned repo on the host does not have. `run --rm` also lets you migrate while the app is down.
# Show executed and pending migrations on the data DB
docker compose run --rm openwa-api npm run migration:show:prod
# Apply pending migrations to the data DB
docker compose run --rm openwa-api npm run migration:run:prod
# Roll back the most recent data-DB migration
docker compose run --rm openwa-api npm run migration:revert:prod
The main (auth/audit) database has its own migration set, which matters only once you have turned
its synchronize off. Inspect and run it with the :main:prod variants:
docker compose run --rm openwa-api npm run migration:show:main:prod
docker compose run --rm openwa-api npm run migration:run:main:prod
A major upgrade can change API routes, config keys, or webhook payloads. Check the CHANGELOG and the target release's notes first, then update your integrations. When in doubt, rehearse the upgrade against a backup in staging before touching production.
- With
WEBHOOK_SSRF_PROTECT=false, deliveries no longer follow redirects: a receiver that answers3xxnow fails the delivery. SetWEBHOOK_SSRF_REDIRECTS=trueif the redirect is legitimate.SSRF_ALLOWED_HOSTSentries must also resolve at registration time, and each delivery pins its connection to the addresses the name resolves to for that delivery; a name that does not resolve yet fails the webhook create/update with a400. - Plugin installs from a URL require a
#sha256=<64 hex>pin underNODE_ENV=production(the compose default),https:URLs included. Action required: catalog installs without a pin fragment now fail. Pin the URL, or setPLUGIN_INSTALL_REQUIRE_PIN=false. Plain-http:installs already needed the pin since v0.19.0.
- Production boot refuses a set
API_MASTER_KEYshorter than 32 characters (unset stays allowed — first boot generates one). If your key is short, strengthen it before upgrading; the boot error names the fix. POST /sessions/:sessionId/messages/send-catalogandPUT /api/settingsare removed — both always answered501, so nothing that worked before stops working. Calls to the old URLs now get404.GET /api/settingsandsend-productare unchanged.- The session routes' path parameter is uniformly
{sessionId}inopenapi.jsonand the Prometheus route labels (22 routes previously mixed{id}). The URLs are unchanged — only generated clients need regenerating. See API conventions.
Migrate SQLite → PostgreSQL
Move to PostgreSQL when you outgrow SQLite — broadly, more than a handful of concurrent sessions, heavy write volume, or a need for higher write concurrency and high availability. See Horizontal Scaling for the decision criteria.
You don't hand-write SQL. OpenWA exposes admin endpoints that export the data database to JSON and import it into whatever backend the running instance is configured for. The flow is: export from the SQLite-backed instance, reconfigure to PostgreSQL, restart, then import.
Only the data database moves: sessions, webhooks, messages, message batches, templates,
Baileys stored messages, LID mappings, persisted chat state (v0.23.4+), plugin instances,
conversation mappings, ingress events, both delivery-failure queues, the outbound
webhook-delivery records, status updates, and automation rules. The main database (API keys,
audit logs)
stays local and is untouched. Since v0.19.0 the export redacts webhook secret and headers
—they are not written to the archive at all— so an exported archive restores its webhooks
unsigned with no custom headers: re-set the signing secret and headers on each webhook after
the import if you rely on them. Since v0.23.4 a session's proxyUrl is redacted the same way, but
only its user:pass part: the scheme and host survive, so a restored session still routes through
the proxy you chose and fails loudly on its next start when that proxy needs credentials, instead
of connecting direct and putting the host's own egress IP in front of WhatsApp. Re-enter the
credentials with PATCH /api/sessions/{sessionId}/proxy after the import. A proxyUrl the URL
parser rejects is cleared rather than copied, since it cannot be proven credential-free.
# 1. Export the data DB to a JSON file (admin key required)
curl -s 'http://localhost:2785/api/infra/export-data' \
-H 'X-API-Key: YOUR_API_KEY' > data-backup.json
# 2. Switch the data database in .env:
# DATABASE_TYPE=postgres
# DATABASE_HOST, DATABASE_USERNAME, DATABASE_PASSWORD (required for postgres)
# Do NOT rely on POSTGRES_BUILTIN here: the bundled compose files declare no
# env_file and forward each key explicitly, and that one is not among them, so a
# value in .env never reaches the container. Under Compose the profile below is
# what starts the bundled database.
# 3. Restart with the postgres profile (brings up the bundled DB and reconnects OpenWA)
docker compose --profile postgres up -d
# 4. Import the JSON into the now-PostgreSQL data DB
curl -s -X POST 'http://localhost:2785/api/infra/import-data' \
-H 'X-API-Key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d @data-backup.json
The export file is also a valid import payload — its top-level tables object is exactly what
import-data consumes, so -d @data-backup.json works as-is. Every export covers the same
sixteen tables, in both tables (the full rows) and counts (how many rows each holds), so you
can confirm what you captured. The row arrays under tables are omitted below for brevity — in a
real export they hold one object per row:
{
"exportedAt": "2026-06-26T02:30:00.000Z",
"dataDbType": "sqlite",
"tables": {
"sessions": [],
"webhooks": [],
"messages": [],
"messageBatches": [],
"templates": [],
"baileysStoredMessages": [],
"lidMappings": [],
"chatStates": [],
"pluginInstances": [],
"conversationMappings": [],
"ingressEvents": [],
"webhookDeliveryFailures": [],
"webhookOutboxEvents": [],
"integrationDeliveryFailures": [],
"statusUpdates": [],
"automationRules": []
},
"counts": {
"sessions": 5,
"webhooks": 12,
"messages": 1500,
"messageBatches": 3,
"templates": 4,
"baileysStoredMessages": 800,
"lidMappings": 60,
"chatStates": 40,
"pluginInstances": 2,
"conversationMappings": 0,
"ingressEvents": 40,
"webhookDeliveryFailures": 0,
"webhookOutboxEvents": 0,
"integrationDeliveryFailures": 0,
"statusUpdates": 25,
"automationRules": 6
},
"skippedTables": [],
"omittedInlineMedia": { "messages": 0, "messageBatches": 0 }
}
A table that genuinely does not exist in the source database (an older schema that predates it)
is skipped and named in the top-level skippedTables array. Since v0.11.0, any other read
failure — a lock, I/O error, or timeout — fails the whole export instead of silently recording
the table as empty, so a 200 response with an empty skippedTables captured every row.
Postgres exports also strip the server-maintained body_ts tsvector column from message rows, so
a Postgres export imports cleanly into SQLite.
The export also bounds the inline base64 media it embeds, at EXPORT_INLINE_MEDIA_BUDGET_BYTES
(8 MiB by default) shared across messages and messageBatches. It spends that budget newest-first
and replaces each over-budget payload with an omitted marker, counting the refusals per table in
omittedInlineMedia. A 200 with an empty skippedTables but a non-zero omittedInlineMedia is a
complete set of rows whose attachments are not all there, so treat export-data as a data move
rather than a media-complete backup. The scripts/backup.sh archive above carries no such budget:
it captures the whole data database, by sqlite3 .backup snapshot or pg_dump, with every payload
intact.
A successful import echoes the counts it wrote, any per-row warnings, and the orphan-engine reconciliation fields (see below):
{
"imported": true,
"counts": {
"sessions": 5,
"webhooks": 12,
"messages": 1500,
"messageBatches": 3,
"templates": 4,
"baileysStoredMessages": 800,
"lidMappings": 60,
"chatStates": 40,
"pluginInstances": 2,
"conversationMappings": 0,
"ingressEvents": 40,
"webhookDeliveryFailures": 0,
"webhookOutboxEvents": 0,
"integrationDeliveryFailures": 0,
"statusUpdates": 25,
"automationRules": 6
},
"warnings": [],
"notices": [],
"restartRequired": false,
"orphanedEngines": [],
"stoppedOrphanEngines": [],
"failedOrphanEngines": []
}
Check imported before you trust the counts. The import runs as one transaction: if any row
fails to insert, the whole import is rolled back and the response is { "imported": false, ... }
with the failing rows listed under warnings — but counts still reports the rows it attempted,
so non-zero counts alone do not mean success. Verify in this order:
- Confirm
imported === true. If it isfalse, the data was not written — readwarnings, fix the cause, and re-run the import. - Only then compare the import
countsagainst the exportcountsto confirm everything landed.
POST /api/infra/import-data clears the target data tables first, then inserts the payload.
Run it only against a fresh target you intend to overwrite, never against a database with live
data you want to keep.
Restoring while sessions are running
The replace deletes every session the backup does not contain — but a WhatsApp engine already
running for such a session would keep writing into the freshly restored tables. Since v0.11.0 the
import pre-flight checks for this and refuses with 409 Conflict, listing the affected
session ids. You have two ways through:
- Stop them in-request (preferred): pass
"stopOrphans": truein the payload. The import stops each orphaned engine before the replace runs, andrestartRequiredstaysfalse. The response reports the outcome per engine instoppedOrphanEnginesandfailedOrphanEngines. - Proceed anyway: pass
"force": true. The orphaned engines keep running until the next process restart, and the response carriesrestartRequired: truewith their ids inorphanedEngines— restart promptly to close that window.
# Merge the flag into the export payload, then import
jq '. + {stopOrphans: true}' data-backup.json > data-backup-import.json
curl -s -X POST 'http://localhost:2785/api/infra/import-data' \
-H 'X-API-Key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d @data-backup-import.json
Non-fatal reconciliation details land in notices (unlike warnings, they never roll the import
back). One such notice counts normalized statuses: an imported session whose backed-up status was
an active one (ready, initializing, qr_ready, authenticating, action_required) is restored
as disconnected (since v0.21.0), so it is startable with POST /api/sessions/:id/start or
adoptable by AUTO_START_SESSIONS without a process restart. After a committed restore, OpenWA
also reloads its in-memory LID→phone mirror from the
restored lid_mappings rows, so no restart is needed for identity resolution to match the backup.
Migrate media storage (local ↔ S3 / MinIO)
Media files move with the same export → reconfigure → import pattern, over a tar.gz archive. Local → built-in MinIO, local → external S3, S3 → local, and MinIO → external S3 are all supported.
# 1. Check what you're about to move (admin key required)
curl -s 'http://localhost:2785/api/infra/storage/files/count' \
-H 'X-API-Key: YOUR_API_KEY'
# { "storageType": "local", "count": 150, "sizeBytes": 15728640, "sizeMB": "15.00" }
# 2. Export all files. The archive is written under data/ (it survives the restart) and the
# response gives you its path.
curl -s 'http://localhost:2785/api/infra/storage/export' \
-H 'X-API-Key: YOUR_API_KEY'
# { "message": "Storage export completed",
# "download": "data/exports/storage-export-1750000000000-<uuid>.tar.gz" }
# The path is relative to the server's working directory, not absolute.
# 3. Switch storage in .env. The credentials are REQUIRED either way: nothing fills them
# in for you, and MinIO itself refuses to start with empty root credentials (it reads
# the same two variables). MINIO_BUILTIN is not forwarded by the bundled compose files,
# so under Compose the --profile minio flag in step 4 is what starts the bundled MinIO.
# STORAGE_TYPE=s3
# S3_ACCESS_KEY_ID=<choose one>
# S3_SECRET_ACCESS_KEY=<choose one>
# S3_BUCKET=openwa
# S3_ENDPOINT=http://minio:9000 # bundled MinIO or any S3-compatible store; leave
# # unset for AWS S3, which derives its own endpoint
# 4. Restart. The bundled MinIO sits behind a compose profile, so a plain
# `docker compose up -d` would leave it out and storage would stay on local disk.
docker compose --profile minio up -d
# 5. Import the archive into the new storage (use the path from step 2)
curl -s -X POST 'http://localhost:2785/api/infra/storage/import' \
-H 'X-API-Key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{"filePath": "/app/data/exports/storage-export-1750000000000-<uuid>.tar.gz"}'
# { "imported": true, "count": 150, "storageType": "s3" }
Three constraints to plan around:
filePathmust point inside thedata/directory. The import rejects any path outside it with400 Bad Request— a path-traversal guard. Keep the archive wherestorage/exportwrote it.- The archive is swept after a TTL (
STORAGE_EXPORT_TTL_MS, default 1 hour). Import it before then, or re-export. - A corrupt or truncated archive returns
400 Bad Requestwith the underlying reason (gzip error, read failure, or a breached size/entry cap). Since v0.11.0 these failures fail the request cleanly — previously a corrupt gzip could take the server down mid-import.
Switch Redis (cache and queue)
Redis backs the cache and the BullMQ job queue. Cache data is ephemeral and rebuilds from the database on demand, so there is no migration endpoint — point at the new instance and restart.
# In .env:
REDIS_ENABLED=true
REDIS_BUILTIN=false # external Redis
REDIS_HOST=your-redis-host
REDIS_PORT=6379
REDIS_PASSWORD=optional
The queue is the part that needs care: switching Redis can strand jobs that are mid-flight.
Two queues live in Redis: webhook-queue (outbound webhook deliveries) and ingress-queue (inbound
integration events). Before you change REDIS_HOST and restart, wait until both are empty so
in-flight jobs aren't lost. Read pending job counts from the
Bull Board dashboard at /api/admin/queues (an admin key that is not restricted to specific
sessions) — it shows the live depth of
both queues. When neither reports a waiting or active job, it's safe to switch.
The Bull Board UI's obliterate action requires the queue to be paused first, and since v0.23.3
(Bull Board 9) it offers a force option for getting past jobs that are still active. Forcing it
deletes those active jobs too, so their deliveries never reach a final attempt and no row appears in
the delivery-failure log for them. Bull Board 8 refused
the obliterate outright while jobs were active. Drain the queue as described above instead. The
route stays admin-only, behind the same ADMIN key check as the rest of /api/admin/queues.
GET /api/infra/status covers only the webhook queueThe response carries a single queue.webhooks block, read live from webhook-queue, where
pending is waiting plus active plus delayed. There is no block for ingress-queue, and the counts
fall back to { pending: 0, completed: 0, failed: 0 } when the queue is disabled or Redis is
unreachable, so a zero there is not by itself proof the queues are drained. Read /api/admin/queues
for the ingress queue and to tell a real zero from a degraded one.
Move sessions to a new host
A session's auth is engine-specific credential data tied to one host's data directory. To move a session, copy its auth directory and carry its database row, then start the new host so it reconnects against the copied credentials — no fresh QR scan.
# 1. Stop both instances so the auth files aren't mid-write
# (on each host) docker compose down
# 2. Copy the session's auth directory to the new host
rsync -avz --progress \
old-host:/path/to/data/sessions/ \
new-host:/path/to/data/sessions/
# Baileys engine: copy ./data/baileys/ instead.
# 3. Carry the database rows (sessions, webhooks, messages) with the export/import flow:
# on the old host → GET /api/infra/export-data > data-backup.json
# on the new host → POST /api/infra/import-data with that file
# 4. Start the new host
# (on new host) docker compose up -d
# 5. Verify it reconnected without a QR prompt
curl -s 'http://localhost:2785/api/sessions' -H 'X-API-Key: YOUR_API_KEY'
After startup, the session re-initializes against the copied auth and reconnects. If the auth
was truncated or corrupted in transit, the session fails to connect — re-scan the QR code for it
to recover. Since v0.23.3 a session that was linked before and comes back to the QR stage logs a relink_required warning, so the server log names the problem instead of leaving you to infer it from the QR. See Sessions for the connection lifecycle.
Roll back a bad migration
If an upgrade or data move goes wrong, restore the backups you took. Rolling back is the reverse of the upgrade: stop, restore databases + auth + config, move the code back to the previous release, start, verify.
# 1. Stop the new version
docker compose down
# 2. Restore the databases
# Main (always): cp ./backups/main-YYYYMMDD.sqlite ./data/main.sqlite
# SQLite: cp ./backups/openwa-YYYYMMDD.sqlite ./data/openwa.sqlite
# PostgreSQL: psql "$DATABASE_URL" < ./backups/db-YYYYMMDD.sql
# 3. Restore session auth, both directories
rm -rf ./data/sessions && tar -xzf ./backups/sessions-YYYYMMDD.tar.gz -C .
rm -rf ./data/baileys && tar -xzf ./backups/baileys-YYYYMMDD.tar.gz -C .
# Rolling back past v0.23.5 needs both, on either engine: its first boot renames each session's
# auth directory from the session name to the session id and keeps nothing behind, so an older
# image finds no directory and starts every session at a QR code. The backup must PREDATE the
# first start on v0.23.5; one taken after the upgrade does not qualify. The same holds for a
# rollback that crosses a browser major: every amd64 rollback past v0.23.5 does (Chrome for
# Testing 146 to 153), and an arm64 one can, since that image runs whatever chromium Debian
# shipped when it was built. An older Chrome deletes the IndexedDB a newer one opened, which is
# where whatsapp-web.js keeps the WhatsApp login. A session first paired on v0.23.5 is in no
# such backup and must be paired again.
# 4. Move the code back to the release you are rolling back to. Do this BEFORE restoring config:
# docker-compose.yml is tracked, so git refuses to switch onto a tag whose copy of it differs
# from the one sitting in your working tree. The repo's compose file builds the image from
# source; a deployment pinned to a published image bumps the tag in its compose file and runs
# `docker compose pull` in place of --build.
git checkout v<previous-version>
# 5. Restore config over the checkout, then start it
cp ./backups/.env ./backups/docker-compose.yml .
docker compose up -d --build
# 6. Verify
curl -f http://localhost:2785/api/health
The data-DB migrations already ran at boot on the new version, so when you are restoring from a
post-migration backup, revert them in reverse order with docker compose run --rm openwa-api npm run migration:revert:prod
(and the :main:prod variant for the auth/audit DB) until the schema matches the version
you're rolling back to. Restoring a pre-migration database file sidesteps this entirely — which
is why you back up before migrating.
Common migration issues
| Problem | Likely cause | Fix |
|---|---|---|
403 Forbidden on an /api/infra/* call | API key lacks the ADMIN role | Use an admin key. See Authentication. |
import-data wiped data you wanted to keep | Import replaces, it does not merge | Restore from backup; only import into a fresh target. |
import-data returns 409 Conflict | Restore would orphan an engine running for a session the backup lacks | Stop the listed sessions first, retry with stopOrphans: true (preferred), or force: true and restart promptly. |
storage/import returns 400 Bad Request | filePath is outside data/, the TTL swept the archive, or the archive is corrupt | Use the path storage/export returned, import within the TTL window, and re-export a damaged archive. |
| Session won't reconnect after a move | Auth data truncated or corrupted in transit | Re-scan the QR code for that session. |
Permission denied on ./data | File ownership mismatch after copy | chown -R the data dir to the container's runtime user. |
| Migration command errors on the prod image | Ran a non-:prod script (needs the TS toolchain) | Use migration:run:prod (and :main:prod for the auth/audit DB). |
Next steps
- Deployment — production setup, profiles, and health checks.
- Horizontal Scaling — when to adopt PostgreSQL, Redis, and S3.
- Troubleshooting — diagnosing reconnection and config problems.