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.7.6. 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
ghcr.io/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.
Upgrade to a new version
OpenWA ships as a Docker image on GitHub Container Registry (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:
{ "status": "ok", "timestamp": "2026-06-26T10:00:00.000Z", "version": "0.7.6" }
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.
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, and LID mappings. The main database (API keys, audit logs) stays local and is untouched.
# 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 seven
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": []
},
"counts": {
"sessions": 5,
"webhooks": 12,
"messages": 1500,
"messageBatches": 3,
"templates": 4,
"baileysStoredMessages": 800,
"lidMappings": 60
}
}
A successful import echoes the counts it wrote and any per-row warnings:
{
"imported": true,
"counts": {
"sessions": 5,
"webhooks": 12,
"messages": 1500,
"messageBatches": 3,
"templates": 4,
"baileysStoredMessages": 800,
"lidMappings": 60
},
"warnings": []
}
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.
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" }
Two 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.
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. |
storage/import returns 400 Bad Request | filePath is outside data/, or the TTL swept the archive | Use the path storage/export returned, and import within the TTL window. |
| 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.