Deploy OpenWA to production with Docker
This guide takes you from a fresh server to a hardened OpenWA deployment: the Compose stack and its profiles, the environment you must set, persistent storage, a TLS reverse proxy, health checks, and how to size the host. By the end you have a running API behind HTTPS that survives restarts and orchestrator probes.
OpenWA ships as one self-contained image. The NestJS REST API, the management dashboard,
and the real-time Socket.IO channel all share one port (2785) — there is no separate
dashboard container to deploy. OpenWA targets release v0.7.6.
- A Linux host with Docker Engine and the Docker Compose plugin.
- A domain name and TLS certificate (or a proxy that issues one, e.g. Caddy or Traefik with Let's Encrypt).
- A first-pass read of Configuration for the full environment-variable list.
OpenWA keeps live WhatsApp engine state in an in-memory map. Run one API container per session-data volume. Two replicas writing the same WhatsApp auth directory corrupt the session and force a logout. See Horizontal scaling for why and what a scale-out design would require.
How the pieces fit
The image bundles everything the API needs to boot with zero external dependencies. The optional datastores attach only when you turn on a Compose profile.
The default path — SQLite, local-disk storage, in-memory cache — needs no companion containers. PostgreSQL, Redis, and MinIO are opt-in.
Start the Compose stack
The repository ships a hardened docker-compose.yml. The default profile runs the API with
SQLite and local file storage, so a minimal deployment needs no external database.
git clone https://github.com/rmyndharis/OpenWA.git
cd OpenWA
cp .env.example .env
# Edit .env — at minimum set a strong API_MASTER_KEY (see "Required environment").
docker compose up -d
The container publishes port 2785 to 127.0.0.1 only, so it is reachable from the host
but not directly from the network. Confirm it came up healthy before exposing it:
curl http://localhost:2785/api/health
{ "status": "ok", "timestamp": "2026-06-26T10:00:00.000Z", "version": "0.7.6" }
Opt into PostgreSQL, Redis, or MinIO
The built-in datastores live behind Compose profiles. Enable them by name and set the matching environment variables so the API connects to the new service.
# API + built-in PostgreSQL
docker compose --profile postgres up -d
# API + PostgreSQL + Redis + MinIO
docker compose --profile full up -d
| Profile | Adds | Requires |
|---|---|---|
postgres | PostgreSQL 16 | DATABASE_TYPE=postgres, a non-empty DATABASE_PASSWORD |
redis | Redis 7 | REDIS_ENABLED=true |
minio | MinIO (S3-compatible) | STORAGE_TYPE=s3, S3_ACCESS_KEY, S3_SECRET_KEY |
full | All of the above | The union of the rows above |
The datastore containers refuse to start with an empty password, and OpenWA's production boot guard rejects empty or placeholder secrets — so a misconfiguration fails fast at boot instead of starting insecurely.
Required environment
Copy .env.example to .env and review it. The variables below matter most for a
production deployment. For the complete list, see the Configuration reference.
| Variable | Purpose | Production guidance |
|---|---|---|
NODE_ENV | Runtime mode | Set to production. |
API_MASTER_KEY | Master API key | Set this. Generate with openssl rand -base64 32. Boot is refused with an empty or placeholder value. |
DATABASE_TYPE | Database backend | sqlite (default) or postgres. |
STORAGE_TYPE | Media storage backend | local (default) or s3. |
ENGINE_TYPE | WhatsApp engine | whatsapp-web.js (default, Chromium-based) or baileys (browser-free). Leave blank to let the dashboard's Infrastructure → Engine setting decide. |
SESSION_DATA_PATH | Where WhatsApp auth state is stored | Defaults to /app/data/sessions in the container. |
CORS_ORIGINS | Allowed browser origins | The wildcard * is refused in production. Set explicit origins, e.g. https://dashboard.yourdomain.com. |
ENABLE_SWAGGER | Interactive API docs at /api/docs | See Swagger in production. |
API_MASTER_KEY, database passwords, and S3 credentials must come from your environment or a
secrets manager — never hardcode them in the image or in version control. For defense in
depth, set API_KEY_PEPPER so a database leak alone cannot precompute key hashes. Setting or
changing the pepper invalidates existing key hashes, so re-issue keys afterward.
If you do not set API_MASTER_KEY, OpenWA generates a random admin key on first boot, prints
it in the startup banner, and writes it to data/.api-key. For a stable key across restarts,
set API_MASTER_KEY explicitly. See Authentication for the full
picture.
Persist data with volumes
All durable state lives under /app/data in the container, mounted to the named volume
openwa-data. This single volume holds:
- WhatsApp session auth — the linked-device credentials. Losing this forces a QR re-scan.
- SQLite databases —
main.sqlite(API keys and audit log, always SQLite) andopenwa.sqlite(user data, whenDATABASE_TYPE=sqlite). - Local media — when
STORAGE_TYPE=local. - Plugins, exports, and generated config.
volumes:
- openwa-data:/app/data
If you enable PostgreSQL, Redis, or MinIO, each gets its own named volume (postgres-data,
redis-data, minio-data) so its data survives container recreation.
The session-auth directory and main.sqlite are the two pieces of state you cannot
regenerate — main.sqlite carries every API key and the audit log. Back up the whole volume
before upgrades, host moves, or anything that touches storage. The repo's scripts/backup.sh
captures both databases, sessions, and local media in one archive. See the
Migration guide.
Fix file ownership on bind mounts
The container runs as a non-root user and drops to it via gosu after the entrypoint
prepares /app/data. If you bind-mount a host directory instead of using the named volume
and hit "permission denied", fix ownership on the host:
sudo chown -R $(id -u):$(id -g) ./data
Put a reverse proxy and TLS in front
OpenWA does not terminate TLS itself. Put a reverse proxy (nginx, Caddy, Traefik, or a cloud
load balancer) in front of port 2785 and let it handle HTTPS. Because the REST API,
dashboard, and Socket.IO real-time channel all share one port, the proxy must forward
WebSocket upgrades and allow long-lived connections.
If you run behind a proxy and use per-key IP allowlists, also set TRUSTED_PROXIES to the
proxy's address or subnet so the real client IP is read from X-Forwarded-For. Left unset,
OpenWA ignores that header (preventing IP spoofing).
nginx
server {
listen 443 ssl;
server_name api.example.com;
# ssl_certificate / ssl_certificate_key ...
location / {
proxy_pass http://127.0.0.1:2785;
proxy_http_version 1.1;
# Required for the Socket.IO real-time channel
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long-lived connections for real-time updates
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
}
Traefik
Traefik forwards WebSocket upgrades automatically, so a normal HTTP router works. Two things keep a public deployment stable:
- Route over the Docker network — don't also publish the host port. If OpenWA both
publishes
2785:2785to the host and is routed through Traefik, every request makes an extra hop through Docker'sdocker-proxy. Because OpenWA holds a long-lived Socket.IO connection per client, those held-open connections accumulate across both hops and can exhaust the upstream pool — the dashboard, API, and real-time channel then return504together whilecurl …/api/health/readyon the host still returns200. Put the service on the proxy's shared network andexposethe port internally instead of publishing it. - Use generous idle timeouts (for example 600s) so the proxy does not cut the persistent Socket.IO connection.
The example below uses the published image ghcr.io/rmyndharis/openwa:latest as a drop-in
alternative to building from source. The repo's bundled openwa-api service builds the image
locally (build: { context: ., dockerfile: Dockerfile }); if you followed the git clone path
above, either swap the image: line for that same build: block, or pull the published image
instead of cloning. The Traefik labels and expose block are identical either way.
services:
openwa:
image: ghcr.io/rmyndharis/openwa:latest # or: build: { context: ., dockerfile: Dockerfile }
expose:
- '2785' # internal only — no host port mapping when Traefik is on this network
networks: [proxy]
labels:
- traefik.enable=true
- traefik.http.routers.openwa.rule=Host(`api.example.com`)
- traefik.http.routers.openwa.entrypoints=websecure
- traefik.http.routers.openwa.tls.certresolver=le
- traefik.http.services.openwa.loadbalancer.server.port=2785
networks:
proxy:
external: true
A dashboard stuck on "Connecting…" while localhost is healthy almost always points to the
proxy hop, not the app. Since all three surfaces share :2785, a choked upstream takes them
all down at once.
Expose or hide Swagger in production
OpenWA serves interactive API docs (Swagger UI) at /api/docs, controlled by ENABLE_SWAGGER:
ENABLE_SWAGGER=true # expose the docs at /api/docs
ENABLE_SWAGGER=false # disable on a publicly exposed deployment
Swagger is convenient during integration but publishes your full API surface. On an
internet-facing deployment, set ENABLE_SWAGGER=false or restrict /api/docs at the reverse
proxy.
Wire up health checks
All health endpoints live under the /api prefix, require no API key, and are exempt from
rate limiting — point your load balancer and orchestrator at them directly.
| Endpoint | Purpose | Status codes |
|---|---|---|
GET /api/health | Basic check — returns status, timestamp, version | 200 |
GET /api/health/live | Liveness — static ok; a transient dependency outage does not fail it, so the orchestrator won't kill a recovering pod | 200 |
GET /api/health/ready | Readiness — probes both databases and returns 503 while draining during graceful shutdown | 200 / 503 |
Use /api/health/live for liveness probes and /api/health/ready for readiness and load
balancer checks. The bundled Compose file already wires /api/health/ready into the container
healthcheck (interval 30s, timeout 10s, 3 retries, 30s start period):
curl -i http://localhost:2785/api/health/ready
HTTP/1.1 200 OK
content-type: application/json
{"status":"ok","details":{"mainDatabase":{"status":"up"},"dataDatabase":{"status":"up"}}}
A 503 here means a database probe failed or the process is draining during shutdown — the
orchestrator should stop routing traffic until it returns 200.
Optional: Prometheus metrics
OpenWA exposes Prometheus-format metrics at GET /api/metrics, but the endpoint is disabled
by default (returns 404) until you set METRICS_TOKEN. Once set, scrapers must send
Authorization: Bearer <token>; the token is separate from your API key. This endpoint is not
part of the OpenAPI surface — it serves Prometheus text exposition format, not JSON.
METRICS_TOKEN=change-me-to-a-long-random-string
curl -H "Authorization: Bearer change-me-to-a-long-random-string" \
http://localhost:2785/api/metrics
The exported series are openwa_up, the three process gauges openwa_process_uptime_seconds,
openwa_process_resident_memory_bytes, and openwa_process_heap_used_bytes,
openwa_sessions_total, openwa_sessions_active, openwa_sessions{status="..."},
openwa_messages_total{direction="outgoing"} and openwa_messages_total{direction="incoming"},
and openwa_messages_failed_total. OpenWA does not export request-rate, latency, or Node default
(nodejs_*) metrics — scrape an external exporter (cAdvisor, node-exporter) for host signals.
Size the host
Sizing depends heavily on engine choice and message volume. The default whatsapp-web.js
engine spawns a Chromium instance per session (~300–500 MB RAM each); ENGINE_TYPE=baileys is
far lighter because it has no browser. Treat the table below as starting guidance and size up
from your own monitoring.
| Sessions | RAM | CPU | Disk |
|---|---|---|---|
| 1–5 | 2 GB | 2 cores | 20 GB |
| 5–10 | 4 GB | 4 cores | 50 GB |
| 10–20 | 8 GB | 8 cores | 100 GB |
| 20+ | 16 GB+ | 16+ cores | 200 GB+ |
The Compose file caps container memory via OPENWA_MEM_LIMIT (default 2g). Raise it for many
concurrent sessions:
OPENWA_MEM_LIMIT=8g docker compose up -d
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Container exits immediately, logs mention a placeholder secret | API_MASTER_KEY or a datastore password is empty/placeholder | Set a real value; the production boot guard refuses to start without one. |
postgres profile container won't initialize | DATABASE_PASSWORD is empty | Set a non-empty DATABASE_PASSWORD; the Postgres image refuses an empty password. |
| Browser requests blocked from the dashboard | CORS_ORIGINS=* in production | Set explicit origins; the wildcard is refused outside development. |
Dashboard stuck on "Connecting…" but /api/health returns 200 on the host | Double network hop (host publish and proxy) exhausting connections | Route only through the proxy; expose the port internally instead of publishing it. |
| "Permission denied" writing to a bind-mounted data dir | Host directory not owned by the container user | sudo chown -R $(id -u):$(id -g) ./data. |
| Session stuck at "authenticating" after scanning the QR | whatsapp-web.js picked an incompatible WhatsApp Web build | Pin WWEBJS_WEB_VERSION to a known-good version, or raise WWEBJS_AUTH_TIMEOUT_MS on slow hosts. |
For more, see Troubleshooting & FAQ.
Next steps
- Configuration reference — every environment variable, with defaults.
- Migration guide — upgrades, backups, and moving data between hosts.
- Horizontal scaling — why OpenWA runs one instance per session volume.
- Authentication — managing API keys and access control.