Skip to main content
Version: v0.23.5

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.

Back up session auth before anything else

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.

Prerequisites
  • 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 the ADMIN role — a read/send key returns 403. See Authentication.
  • Shell access to the host (or container) and roughly twice your current data size in free disk.
  • Every docker compose command below targets the repository's default docker-compose.yml. If you run the single-container docker compose -f docker-compose.dev.yml up -d stack instead, add -f docker-compose.dev.yml to each of them and write openwa wherever a command names the openwa-api service. Do not carry the flag onto the --profile postgres commands: those services exist only in the production file. The two files also store data differently, a bind-mounted ./data versus the named openwa-data volume, so mixing them points the stack at the wrong data.
  • This page targets OpenWA v0.23.5. Confirm your running version with GET /api/health and 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_dump for 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 .env and docker-compose.yml.
  • Current version recorded with GET /api/health and your API key (the version field 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/
Two databases, one local

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.

Or run the bundled backup script

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
Read the release notes before a major upgrade

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.

Breaking in v0.20.0: two config changes to check before you pull the image
  • With WEBHOOK_SSRF_PROTECT=false, deliveries no longer follow redirects: a receiver that answers 3xx now fails the delivery. Set WEBHOOK_SSRF_REDIRECTS=true if the redirect is legitimate. SSRF_ALLOWED_HOSTS entries 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 a 400.
  • Plugin installs from a URL require a #sha256=<64 hex> pin under NODE_ENV=production (the compose default), https: URLs included. Action required: catalog installs without a pin fragment now fail. Pin the URL, or set PLUGIN_INSTALL_REQUIRE_PIN=false. Plain-http: installs already needed the pin since v0.19.0.
Breaking in v0.19.0 — one action required before you pull the image
  • Production boot refuses a set API_MASTER_KEY shorter 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-catalog and PUT /api/settings are removed — both always answered 501, so nothing that worked before stops working. Calls to the old URLs now get 404. GET /api/settings and send-product are unchanged.
  • The session routes' path parameter is uniformly {sessionId} in openapi.json and 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:

  1. Confirm imported === true. If it is false, the data was not written — read warnings, fix the cause, and re-run the import.
  2. Only then compare the import counts against the export counts to confirm everything landed.
Import replaces, it does not merge

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": true in the payload. The import stops each orphaned engine before the replace runs, and restartRequired stays false. The response reports the outcome per engine in stoppedOrphanEngines and failedOrphanEngines.
  • Proceed anyway: pass "force": true. The orphaned engines keep running until the next process restart, and the response carries restartRequired: true with their ids in orphanedEngines — 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:

  • filePath must point inside the data/ directory. The import rejects any path outside it with 400 Bad Request — a path-traversal guard. Keep the archive where storage/export wrote 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 Request with 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.

Drain the queue before switching Redis

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.

Forcing the board's obliterate action drops jobs with no delivery-failure record

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 queue

The 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
Roll back schema migrations too

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​

ProblemLikely causeFix
403 Forbidden on an /api/infra/* callAPI key lacks the ADMIN roleUse an admin key. See Authentication.
import-data wiped data you wanted to keepImport replaces, it does not mergeRestore from backup; only import into a fresh target.
import-data returns 409 ConflictRestore would orphan an engine running for a session the backup lacksStop the listed sessions first, retry with stopOrphans: true (preferred), or force: true and restart promptly.
storage/import returns 400 Bad RequestfilePath is outside data/, the TTL swept the archive, or the archive is corruptUse the path storage/export returned, import within the TTL window, and re-export a damaged archive.
Session won't reconnect after a moveAuth data truncated or corrupted in transitRe-scan the QR code for that session.
Permission denied on ./dataFile ownership mismatch after copychown -R the data dir to the container's runtime user.
Migration command errors on the prod imageRan a non-:prod script (needs the TS toolchain)Use migration:run:prod (and :main:prod for the auth/audit DB).

Next steps​