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.10.2.
- 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.10.2" }
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.
Cloudflare Tunnel
If your host is behind NAT, on a home network, or otherwise unreachable from the public
internet, you can replace the reverse proxy with a Cloudflare Tunnel
(cloudflared). The tunnel opens a long-lived outbound connection to Cloudflare, so no
inbound ports need to be opened and no public IP is required. WebSocket upgrades for the
Socket.IO channel are forwarded natively. See Cloudflare Tunnel
for the full setup (host install and Docker sidecar), the OpenWA environment to set, and the
free-plan limits to be aware of.
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. v0.10.0 adds three counters:
openwa_webhook_delivery_failures_total, openwa_session_reconnect_attempts_total, and
openwa_session_reconnect_loop_alerts_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.