Skip to main content
Version: v0.7.6

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.

Prerequisites
  • 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.
Run one instance per session volume

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
ProfileAddsRequires
postgresPostgreSQL 16DATABASE_TYPE=postgres, a non-empty DATABASE_PASSWORD
redisRedis 7REDIS_ENABLED=true
minioMinIO (S3-compatible)STORAGE_TYPE=s3, S3_ACCESS_KEY, S3_SECRET_KEY
fullAll of the aboveThe 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.

VariablePurposeProduction guidance
NODE_ENVRuntime modeSet to production.
API_MASTER_KEYMaster API keySet this. Generate with openssl rand -base64 32. Boot is refused with an empty or placeholder value.
DATABASE_TYPEDatabase backendsqlite (default) or postgres.
STORAGE_TYPEMedia storage backendlocal (default) or s3.
ENGINE_TYPEWhatsApp enginewhatsapp-web.js (default, Chromium-based) or baileys (browser-free). Leave blank to let the dashboard's Infrastructure → Engine setting decide.
SESSION_DATA_PATHWhere WhatsApp auth state is storedDefaults to /app/data/sessions in the container.
CORS_ORIGINSAllowed browser originsThe wildcard * is refused in production. Set explicit origins, e.g. https://dashboard.yourdomain.com.
ENABLE_SWAGGERInteractive API docs at /api/docsSee Swagger in production.
Never commit secrets

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 databasesmain.sqlite (API keys and audit log, always SQLite) and openwa.sqlite (user data, when DATABASE_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.

Back up session auth before any maintenance

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:

  1. Route over the Docker network — don't also publish the host port. If OpenWA both publishes 2785:2785 to the host and is routed through Traefik, every request makes an extra hop through Docker's docker-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 return 504 together while curl …/api/health/ready on the host still returns 200. Put the service on the proxy's shared network and expose the port internally instead of publishing it.
  2. 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
One port, shared fate

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.

EndpointPurposeStatus codes
GET /api/healthBasic check — returns status, timestamp, version200
GET /api/health/liveLiveness — static ok; a transient dependency outage does not fail it, so the orchestrator won't kill a recovering pod200
GET /api/health/readyReadiness — probes both databases and returns 503 while draining during graceful shutdown200 / 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.

SessionsRAMCPUDisk
1–52 GB2 cores20 GB
5–104 GB4 cores50 GB
10–208 GB8 cores100 GB
20+16 GB+16+ cores200 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

SymptomCauseFix
Container exits immediately, logs mention a placeholder secretAPI_MASTER_KEY or a datastore password is empty/placeholderSet a real value; the production boot guard refuses to start without one.
postgres profile container won't initializeDATABASE_PASSWORD is emptySet a non-empty DATABASE_PASSWORD; the Postgres image refuses an empty password.
Browser requests blocked from the dashboardCORS_ORIGINS=* in productionSet explicit origins; the wildcard is refused outside development.
Dashboard stuck on "Connecting…" but /api/health returns 200 on the hostDouble network hop (host publish and proxy) exhausting connectionsRoute only through the proxy; expose the port internally instead of publishing it.
"Permission denied" writing to a bind-mounted data dirHost directory not owned by the container usersudo chown -R $(id -u):$(id -g) ./data.
Session stuck at "authenticating" after scanning the QRwhatsapp-web.js picked an incompatible WhatsApp Web buildPin WWEBJS_WEB_VERSION to a known-good version, or raise WWEBJS_AUTH_TIMEOUT_MS on slow hosts.

For more, see Troubleshooting & FAQ.

Next steps