Skip to main content
Version: v0.7.6

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 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.

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.
  • 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_dump for PostgreSQL.
  • Session auth backed up./data/sessions (whatsapp-web.js) or ./data/baileys (Baileys).
  • Config saved — your .env and docker-compose.yml.
  • Current version recordedGET /api/health returns the running version.
  • 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/
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.

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
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.

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:

  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.

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:

  • 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.

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

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 depth

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

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

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.
storage/import returns 400 Bad RequestfilePath is outside data/, or the TTL swept the archiveUse the path storage/export returned, and import within the TTL window.
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