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 your session-auth directory (./data/sessions for the
default whatsapp-web.js engine, ./data/baileys for Baileys). 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.
- This page targets OpenWA v0.23.1. Confirm your running version with
GET /api/health.
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 —
./data/sessions(whatsapp-web.js) or./data/baileys(Baileys). - Config saved — your
.envanddocker-compose.yml. - Current version recorded —
GET /api/healthreturns the runningversion. - Disk space confirmed — about 2x current data size, so a copy and the original coexist.
- Previous image tag noted — your rollback target (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, the session-auth directory, 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
# 2. Session auth
# whatsapp-web.js engine (default):
tar -czf ./backups/sessions-$(date +%Y%m%d).tar.gz ./data/sessions
# Baileys engine: back up ./data/baileys instead.
# 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). An upgrade is: back up, pull the new image, run any pending schema
migrations, restart, verify.
# 1. Back up (see above), then stop the running version
docker compose down
# 2. Pull the new image — pin an explicit tag in docker-compose.yml or .env, not :latest
docker compose pull
# 3. Start the new version
docker compose up -d
# 4. Verify health and the running version
curl -f http://localhost:2785/api/health
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.1" }
Run schema migrations
Schema changes are managed by TypeORM migrations, applied explicitly — OpenWA does not
auto-sync the schema. The production image strips the TypeScript toolchain, so use the :prod
script variants, which run the compiled migrations from dist/.
# Show executed and pending migrations on the data DB
npm run migration:show:prod
# Apply pending migrations to the data DB
npm run migration:run:prod
# Roll back the most recent data-DB migration
npm run migration:revert:prod
The main (auth/audit) database has its own migration set. Run it with the :main:prod variants
whenever a release touches that schema:
npm run migration:show:main:prod
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, 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.
# 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)
# POSTGRES_BUILTIN=true # to use the bundled PostgreSQL container
# 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
fifteen 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": [],
"pluginInstances": [],
"conversationMappings": [],
"ingressEvents": [],
"webhookDeliveryFailures": [],
"webhookOutboxEvents": [],
"integrationDeliveryFailures": [],
"statusUpdates": [],
"automationRules": []
},
"counts": {
"sessions": 5,
"webhooks": 12,
"messages": 1500,
"messageBatches": 3,
"templates": 4,
"baileysStoredMessages": 800,
"lidMappings": 60,
"pluginInstances": 2,
"conversationMappings": 0,
"ingressEvents": 40,
"webhookDeliveryFailures": 0,
"webhookOutboxEvents": 0,
"integrationDeliveryFailures": 0,
"statusUpdates": 25,
"automationRules": 6
},
"skippedTables": []
}
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 is a complete backup.
Postgres exports also strip the server-maintained body_ts tsvector column from message rows, so
a Postgres export imports cleanly into SQLite.
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,
"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": "/app/data/exports/storage-export-1750000000000-<uuid>.tar.gz" }
# 3. Switch storage in .env, e.g.:
# STORAGE_TYPE=s3
# MINIO_BUILTIN=true # bundled MinIO; set false and supply S3 creds for external S3
# 4. Restart
docker compose 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.
Pending message and webhook jobs live in Redis. Before you change REDIS_HOST and restart, wait
until both queues are empty so in-flight jobs aren't lost. Read pending job counts from the
Bull Board dashboard at /api/admin/queues (admin key required) — it shows the live depth of
the message and webhook queues. When both report no waiting or active jobs, it's safe to switch.
GET /api/infra/status does not report queue depthThe queue.messages and queue.webhooks blocks in the /api/infra/status response are hardcoded
placeholders — they always read { pending: 0, completed: 0, failed: 0 } regardless of the real
queue. Do not use that endpoint to decide a Redis switch is safe; it will report 0 even with
jobs in flight. The /api/admin/queues dashboard is the only accurate source of pending depth.
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. 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 database + auth + config, pin the previous image, start, verify.
# 1. Stop the new version
docker compose down
# 2. Restore the database
# SQLite: cp ./backups/openwa-YYYYMMDD.sqlite ./data/openwa.sqlite
# PostgreSQL: psql "$DATABASE_URL" < ./backups/db-YYYYMMDD.sql
# 3. Restore session auth
rm -rf ./data/sessions && tar -xzf ./backups/sessions-YYYYMMDD.tar.gz -C .
# 4. Restore config and pin the previous image tag in docker-compose.yml
cp ./backups/.env ./backups/docker-compose.yml .
# 5. Start the previous version and verify
docker compose up -d
curl -f http://localhost:2785/api/health
If the failed upgrade ran TypeORM migrations against a database you are restoring from a
post-migration backup, revert them in reverse order with npm run migration:revert:prod (and
npm run migration:revert:main:prod 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.