Try describing what you need:

OpenWA logo

OpenWA

★ 16.4k

A self-hosted alternative to Twilio

MIT LicenseOpen Source — No Paywall5 Containers — 5 containers — moderate hosting footprintDocker · SourceNeeds Postgres1+ active committer (12mo)

About

OpenWA is an open-source WhatsApp API gateway aimed at developers and operators who need to automate messaging while retaining control over their own infrastructure. It provides a self-hosted...

Community Ratings

No ratings yet

Features

  • REST API gateway
  • Full dashboard
  • Multi-session support
  • Session-scoped API keys
  • Chat-scoped API keys
  • Pluggable database/storage/cache
  • Webhook management
  • API key management
  • Official plugins
  • n8n integration

Details

Last Updated
Oct 30, 2026
Created
Feb 2, 2026
Install Methods
dockersource
Requirements
Node 22 LTSSQLite or PostgreSQLLocal or S3 backup/migration storageRedis (optional cache layer)whatsapp-web.js or Baileys
Authentication
local
API
RESTWEBSOCKETGRPC
Backup & Export
native-exportfile-backupdatabase-dump
Runtime / Stack
Docker
Privacy & Independence
No Cloud Required
User Model
Multi-User
Deployment

Deployment: Docker Compose ✓5 Containers — moderate hosting footprintRuns as 5 containers

Run OpenWA yourself

Pick how much you want to manage.

2 more ways to host

Is this your project? Sponsor a listing

Run OpenWA with Docker Compose

Compose file from the OpenWA project repository.

5 services · database: postgres · infra: postgres, redis · 2 unclassified (docker-socket-proxy, silo)

≈2.0 GB RAM requested (from Docker memory limits)

needs persistent storage (6 volumes)

docker-compose.yml · @ 0fb74b2 · scanned Oct 25, 2026

# OpenWA - Docker Compose Configuration
# Smart Orchestration with Profiles

services:
  # ===== CORE: Docker Socket Proxy (sole container with /var/run/docker.sock access) =====
  docker-proxy:
    image: tecnativa/docker-socket-proxy:v0.5.0
    container_name: openwa-docker-proxy
    restart: unless-stopped
    # Only on the isolated internal network, reachable solely by openwa-api (including any
    # plugin loaded into it), NOT the dashboard or any other peer. `internal: true` also denies the proxy
    # outbound access; it only needs the locally-mounted docker socket.
    networks:
      - internal-docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      # Minimal set that keeps DockerService (src/modules/docker/docker.service.ts) working:
      # PING (health), CONTAINERS (list/inspect/create/start/stop),
      # IMAGES (pull), VOLUMES (createVolume). POST is the proxy's single all-or-nothing
      # method gate for non-GET requests (haproxy.cfg: `deny unless METH_GET || env(POST)`).
      # Two consequences of the pinned proxy config, accepted and documented in SECURITY.md:
      #  - its `DELETE` env flag is dead config (never read), so it is not set here; DELETE is
      #    admitted to enabled paths as a side effect of POST — OpenWA itself never issues
      #    deletes (profile teardown is stop-only, see DockerService.stopManagedService).
      #  - the proxy cannot scope container-create payloads, so a compromised API container
      #    could create containers with host bind-mounts. If you don't use the built-in
      #    datastore orchestration (Dashboard > Infrastructure built-in toggles), disable
      #    this service entirely, e.g. via a docker-compose.override.yml:
      #      services:
      #        docker-proxy:
      #          profiles: ['disabled']
      #    DockerService then reports Docker unavailable and orchestration degrades gracefully.
      CONTAINERS: 1
      IMAGES: 1
      VOLUMES: 1
      PING: 1
      POST: 1
      # Everything else is denied by default (AUTH, SECRETS, NETWORKS, PLUGINS, SWARM, TASKS, SERVICES, CONFIGS, NODES, DISTRIBUTIONS)
    labels:
      - 'com.openwa.service=docker-proxy'
      - 'com.openwa.core=true'

  # ===== CORE: OpenWA Backend API =====
  openwa-api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: openwa-api
    restart: unless-stopped
    # Give the ordered teardown room to finish before Docker SIGKILLs the container, which would cut
    # Chromium, the usage flush and the database close short. Bounded stages in order: drain 3s
    # (SHUTDOWN_DELAY_MS) + ingress reconciler 5s + engines 10s + auto-start wait 5s + webhook drain 5s
    # (WEBHOOK_SHUTDOWN_DRAIN_MS) + usage flush 5s = 33s. An enabled plugin adds 10s. QUEUE_ENABLED and
    # REDIS_ENABLED add 20s (worker close 10s, producer queue close 2s for each of two queues, three
    # Redis quits 2s each): 63s worst case. The process force-exits 67s after the drain and 5s before
    # this deadline. Raising SHUTDOWN_DELAY_MS or WEBHOOK_SHUTDOWN_DRAIN_MS moves that exit later by the
    # same amount, so raise this by the same amount too. The session count does not add: engines stop
    # in parallel.
    stop_grace_period: 75s
    # App network for datastores + the isolated network to reach
    # docker-proxy (DOCKER_HOST). createService() in docker.service.ts attaches
    # orchestrated containers to the literal `openwa-network`, so its name is fixed.
    networks:
      - openwa-network
      - internal-docker
    # Container hardening: Chromium runs with --no-sandbox, so the
    # container itself is the confinement boundary. cap_drop ALL + a minimal re-add
    # for root entrypoint ownership, the gosu drop and init signal forwarding. Once gosu setuids to
    # the openwa user the node/Chromium process keeps NO effective capabilities.
    # read_only rootfs (Chromium's profile lives on the writable /app/data volume;
    # HOME=/tmp + tmpfs absorb stray writes). no-new-privileges blocks setuid escalation.
    security_opt:
      - 'no-new-privileges:true'
    cap_drop:
      - ALL
    cap_add:
      - CHOWN # entrypoint: chown openwa /app/data on the named volume
      - DAC_OVERRIDE # entrypoint: chown across pre-existing files
      - FOWNER
      - KILL # dumb-init: forward signals to the openwa process after gosu drops uid
      - SETGID # gosu: drop to the openwa group
      - SETUID # gosu: drop to the openwa user
    read_only: true
    tmpfs:
      - /tmp
    # Per-container PID ceiling (writes the cgroup pids.max). A fork-bomb guard, NOT an allocation —
    # the kernel only rejects new forks once the count is reached, so a higher limit is free for
    # light containers. The old default (512, from the #243 hardening pass) was picked without
    # accounting for Chromium's multi-process model: whatsapp-web.js runs a full Chromium instance
    # per session (browser + renderer + GPU + zygote + utilities), and WhatsApp Web is process-heavy
    # (service workers/iframes), so ~4 concurrent sessions already approach 512 and a new session's
    # Chromium gets killed mid-spawn — surfacing in the API as `Code: null`. 2048 fits ~8-10 wwjs
    # sessions with startup-spike headroom; Baileys is single-process (no Chromium) and uses far
    # less, so the higher ceiling is a no-op there. Raise via OPENWA_PIDS_LIMIT for larger fleets;
    # do NOT set -1 (unlimited) — that drops the fork-bomb guard. See #636.
    pids_limit: ${OPENWA_PIDS_LIMIT:-2048}
    mem_limit: ${OPENWA_MEM_LIMIT:-2g} # tune up for many concurrent sessions
    ports:
      - '127.0.0.1:${API_PORT:-2785}:2785'
    expose:
      - '2785'
    environment:
      # Core
      - NODE_ENV=${NODE_ENV:-production}
      # The image already runs on UTC; stated here so it is a decision rather than an accident. Stored
      # timestamps are UTC on either database whatever this says (the Postgres data connection pins its
      # own session), so overriding it only moves log stamps and the local day the "today" stats count.
      - TZ=${TZ:-UTC}
      # Writable HOME on tmpfs so Chromium's HOME-relative writes don't hit the read_only rootfs
      - HOME=/tmp
      # Chromium resolves its home from the passwd entry (no /home/openwa), ignoring $HOME, so without
      # writable, existing config/cache dirs it hard-crashes at launch on the read_only rootfs. Pin XDG
      # to the tmpfs; the entrypoint pre-creates these owned by openwa. (#254)
      - XDG_CONFIG_HOME=/tmp/.config
      - XDG_CACHE_HOME=/tmp/.cache
      - PORT=2785
      - LOG_LEVEL=${LOG_LEVEL:-info}
      # Database. Forwarded blank by default (`${VAR:-}`) so a dashboard switch saved to
      # data/.env.generated actually applies at runtime — main.ts clears the blank (BLANK_SHADOWED_ENV_KEYS)
      # and the file wins. Set any of these in your .env/host to pin it (a real value keeps top precedence
      # and the dashboard control then shows "managed by environment"). First-run defaults (sqlite) are
      # written to data/.env.generated by the app, so a blank stack still boots SQLite.
      # NOTE: if you run the built-in Postgres MANUALLY (`docker compose --profile postgres up`) instead
      # of via the dashboard, set DATABASE_TYPE=postgres, DATABASE_HOST=postgres, DATABASE_PORT=5432,
      # DATABASE_USERNAME=openwa and DATABASE_PASSWORD in your .env/host — these are no longer defaulted here.
      - DATABASE_TYPE=${DATABASE_TYPE:-}
      - DATABASE_NAME=${DATABASE_NAME:-}
      - DATABASE_HOST=${DATABASE_HOST:-}
      - DATABASE_PORT=${DATABASE_PORT:-}
      - DATABASE_USERNAME=${DATABASE_USERNAME:-}
      # No committed default secret; blank unless the operator sets it (built-in Postgres is provisioned
      # with its own credentials by the orchestrator, not via this line).
      - DATABASE_PASSWORD=${DATABASE_PASSWORD:-}
      # PostgreSQL schema (blank-forwarded like the other DATABASE_* keys so a dashboard-saved
      # POSTGRES_SCHEMA in data/.env.generated applies; a real host value pins). Ignored for sqlite.
      - POSTGRES_SCHEMA=${POSTGRES_SCHEMA:-}
      # TLS to a managed PostgreSQL, blank-forwarded the same way.
      - DATABASE_SSL=${DATABASE_SSL:-}
      - DATABASE_SSL_REJECT_UNAUTHORIZED=${DATABASE_SSL_REJECT_UNAUTHORIZED:-}
      - DATABASE_SYNCHRONIZE=${DATABASE_SYNCHRONIZE:-false}
      # Engine. Forwarded empty by default so the dashboard (Infrastructure > Engine) selects the
      # active engine via data/.env.generated (defaults to whatsapp-web.js); main.ts treats a blank
      # ENGINE_TYPE as unset, so .env.generated wins. Set ENGINE_TYPE in your .env/host to pin an
      # engine (e.g. baileys) — a real value flows through here and keeps top precedence.
      - ENGINE_TYPE=${ENGINE_TYPE:-}
      # Engine launch options (session data path, headless, browser args). Forwarded EMPTY by default
      # so the dashboard (Infrastructure > Engine) selections saved to data/.env.generated apply — like
      # ENGINE_TYPE above. main.ts treats a blank value as unset (.env.generated wins), and the app
      # layer (configuration.ts) supplies the sane container default (headless, ./data/sessions, the
      # sandbox + no-dev-shm flag set) when nothing is set anywhere. Set one on the host/.env to pin it.
      - SESSION_DATA_PATH=${SESSION_DATA_PATH:-}
      - PUPPETEER_HEADLESS=${PUPPETEER_HEADLESS:-}
      - PUPPETEER_ARGS=${PUPPETEER_ARGS:-}
      - PUPPETEER_PROTOCOL_TIMEOUT_MS=${PUPPETEER_PROTOCOL_TIMEOUT_MS:-}
      # Optional WhatsApp Web version override. DEFAULT (empty, "latest", or "auto"): OpenWA auto-resolves a
      # settled build from the third-party wppconnect-team/wa-version registry and pins its remote HTML
      # (fetched into the web.whatsapp.com origin without an integrity check). Set "off" to disable pinning
      # and use the first-party build served by WhatsApp, or an exact version to pin a specific build.
      - WWEBJS_WEB_VERSION=${WWEBJS_WEB_VERSION:-}
      - WWEBJS_WEB_VERSION_REMOTE_PATH=${WWEBJS_WEB_VERSION_REMOTE_PATH:-}
      # Raise whatsapp-web.js's first-boot init wait (default 30000ms) on slow boots — e.g. WSL2 or
      # low-resource hosts where the QR can time out before WA Web loads. Empty = default; see
      # docs/12-troubleshooting-faq.md. (Without this line the var in .env never reaches the container.)
      - WWEBJS_AUTH_TIMEOUT_MS=${WWEBJS_AUTH_TIMEOUT_MS:-}
      # Re-start previously authenticated sessions at boot. Opt-in: the app reads the exact string
      # "true", so a blank forward (nothing set) keeps the default off. Without this line the value in
      # .env never reaches the container and the flag silently does nothing.
      - AUTO_START_SESSIONS=${AUTO_START_SESSIONS:-}
      # Fetching status@broadcast immediately after a fresh pairing can make affected accounts lose
      # the companion at WhatsApp Web's first scheduled reload. Disabled by default; live statuses
      # still arrive normally. Opt in only after validating the account.
      - STATUS_SEED_ON_READY=${STATUS_SEED_ON_READY:-false}
      # Session ownership / multi-node routing (see the "Session ownership" section of .env.example
      # and docs/13). NODE_ID must be STABLE across restarts: the app default (the container
      # hostname) changes on every recreate, making the new container a "new node" that has to wait
      # out its previous identity's lease before re-adopting sessions.
      - NODE_ID=${NODE_ID:-}
      - NODE_URL=${NODE_URL:-}
      - SESSION_LEASE_TTL_MS=${SESSION_LEASE_TTL_MS:-}
      - SESSION_LEASE_HEARTBEAT_MS=${SESSION_LEASE_HEARTBEAT_MS:-}
      - SESSION_TAKEOVER_SWEEP_MS=${SESSION_TAKEOVER_SWEEP_MS:-}
      - SESSION_PROXY_TIMEOUT_MS=${SESSION_PROXY_TIMEOUT_MS:-}
      # Autoreply rules per session (0 = unlimited). Blank forwards to the app default (32); an
      # unforwarded key could not be set from .env at all, which is the whole point of this list.
      - AUTOMATION_MAX_PER_SESSION=${AUTOMATION_MAX_PER_SESSION:-}
      # Storage. Blank-forwarded (see Database note) so a dashboard local↔S3 switch applies; a real
      # host value pins. Credentials use the canonical S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY names the
      # app and dashboard write; the legacy S3_ACCESS_KEY / S3_SECRET_KEY are ALSO forwarded (and read
      # as a fallback by the storage layer) so existing .env files keep working unchanged. S3_REGION is
      # forwarded so external-S3 works.
      - STORAGE_TYPE=${STORAGE_TYPE:-}
      - STORAGE_LOCAL_PATH=${STORAGE_LOCAL_PATH:-}
      - S3_ENDPOINT=${S3_ENDPOINT:-}
      - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-}
      - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:-}
      - S3_ACCESS_KEY=${S3_ACCESS_KEY:-}
      - S3_SECRET_KEY=${S3_SECRET_KEY:-}
      - S3_REGION=${S3_REGION:-}
      - S3_BUCKET=${S3_BUCKET:-}
      - S3_KEY_PREFIX=${S3_KEY_PREFIX:-}
      # Chat-media archiving (opt-in). Forwarded explicitly: this file has no env_file, so a
      # variable absent from this list simply never reaches the container and the feature stays off
      # no matter what the operator's .env says.
      - CHAT_MEDIA_ARCHIVE_ENABLED=${CHAT_MEDIA_ARCHIVE_ENABLED:-}
      - CHAT_MEDIA_ARCHIVE_OUTBOUND=${CHAT_MEDIA_ARCHIVE_OUTBOUND:-}
      - CHAT_MEDIA_ARCHIVE_MAX_BYTES=${CHAT_MEDIA_ARCHIVE_MAX_BYTES:-}
      - CHAT_MEDIA_ARCHIVE_TTL_DAYS=${CHAT_MEDIA_ARCHIVE_TTL_DAYS:-}
      - MESSAGE_INLINE_MEDIA=${MESSAGE_INLINE_MEDIA:-}
      - CHAT_MEDIA_ORPHAN_SWEEP_INTERVAL_MS=${CHAT_MEDIA_ORPHAN_SWEEP_INTERVAL_MS:-}
      - CHAT_MEDIA_ORPHAN_GRACE_MS=${CHAT_MEDIA_ORPHAN_GRACE_MS:-}
      # Send pacing (opt-in anti-ban governor). Forwarded explicitly for the same reason as the
      # chat-media block above: without these lines SEND_PACING_ENABLED=true in the operator's .env
      # never reaches the container and every send stays unpaced with no signal anywhere.
      - SEND_PACING_ENABLED=${SEND_PACING_ENABLED:-}
      - SEND_PACING_WARMUP_SCHEDULE=${SEND_PACING_WARMUP_SCHEDULE:-}
      - SEND_PACING_COLD_DAILY_CAP=${SEND_PACING_COLD_DAILY_CAP:-}
      - SEND_PACING_BREAKER_THRESHOLD=${SEND_PACING_BREAKER_THRESHOLD:-}
      - SEND_PACING_BREAKER_COOLDOWN_MS=${SEND_PACING_BREAKER_COOLDOWN_MS:-}
      # Inbound media: the response budget for inline media on the message list, and the download
      # limits. Forwarded so they can be tuned from .env without editing this file.
      - MESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTES=${MESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTES:-}
      - MEDIA_DOWNLOAD_ENABLED=${MEDIA_DOWNLOAD_ENABLED:-}
      - MEDIA_DOWNLOAD_MAX_BYTES=${MEDIA_DOWNLOAD_MAX_BYTES:-}
      - MEDIA_DOWNLOAD_TIMEOUT_MS=${MEDIA_DOWNLOAD_TIMEOUT_MS:-}
      - INBOUND_MEDIA_CONCURRENCY=${INBOUND_MEDIA_CONCURRENCY:-}
      - INBOUND_MEDIA_GLOBAL_CONCURRENCY=${INBOUND_MEDIA_GLOBAL_CONCURRENCY:-}
      - SESSION_PROXY_URL_FETCH=${SESSION_PROXY_URL_FETCH:-}
      # Server-side media conversion (opt-in). The image ships ffmpeg, but without these forwards
      # the feature could not be switched on from .env at all.
      - MEDIA_CONVERSION_ENABLED=${MEDIA_CONVERSION_ENABLED:-}
      - FFMPEG_PATH=${FFMPEG_PATH:-}
      - MEDIA_CONVERSION_TIMEOUT_MS=${MEDIA_CONVERSION_TIMEOUT_MS:-}
      - MEDIA_CONVERSION_MAX_OUTPUT_BYTES=${MEDIA_CONVERSION_MAX_OUTPUT_BYTES:-}
      - MEDIA_CONVERSION_CONCURRENCY=${MEDIA_CONVERSION_CONCURRENCY:-}
      # Redis. Blank-forwarded (see Database note) so a dashboard enable/disable applies; a real host
      # value pins.
      - REDIS_ENABLED=${REDIS_ENABLED:-}
      - REDIS_HOST=${REDIS_HOST:-}
      - REDIS_PORT=${REDIS_PORT:-}
      # Webhook
      - WEBHOOK_TIMEOUT=${WEBHOOK_TIMEOUT:-10000}
      - WEBHOOK_RETRY_DELAY=${WEBHOOK_RETRY_DELAY:-5000}
      - WEBHOOK_DISPATCH_CONCURRENCY=${WEBHOOK_DISPATCH_CONCURRENCY:-16}
      - WEBHOOK_DISPATCH_MAX_QUEUED=${WEBHOOK_DISPATCH_MAX_QUEUED:-1000}
      # Flags the app reads with a bare `=== 'true'` / `!== 'false'` and that have no dashboard
      # route, so without these forwards setting them in .env does nothing under compose.
      # BAILEYS_MARK_ONLINE_ON_CONNECT is the one that bites: its default silences push
      # notifications on the paired phone for as long as the gateway is connected (#871).
      - WEBHOOK_CONTACT_DETAILS=${WEBHOOK_CONTACT_DETAILS:-}
      - BAILEYS_MARK_ONLINE_ON_CONNECT=${BAILEYS_MARK_ONLINE_ON_CONNECT:-}
      - BAILEYS_SYNC_FULL_HISTORY=${BAILEYS_SYNC_FULL_HISTORY:-}
      - BAILEYS_WA_VERSION=${BAILEYS_WA_VERSION:-}
      - ALLOW_UNSIGNED_INGRESS=${ALLOW_UNSIGNED_INGRESS:-}
      - STORE_EPHEMERAL_MESSAGES=${STORE_EPHEMERAL_MESSAGES:-}
      - RESOLVE_LID_TO_PHONE=${RESOLVE_LID_TO_PHONE:-}
      - SIMULATE_TYPING=${SIMULATE_TYPING:-}
      - MCP_ENABLED=${MCP_ENABLED:-}
      - SEARCH_ENABLED=${SEARCH_ENABLED:-}
      - SERVE_DASHBOARD=${SERVE_DASHBOARD:-}
      - CACHE_ENABLED=${CACHE_ENABLED:-}
      - DATABASE_LOGGING=${DATABASE_LOGGING:-}
      - MAIN_DATABASE_SYNCHRONIZE=${MAIN_DATABASE_SYNCHRONIZE:-}
      # Rate limits. Blank values are cleared before lower-priority generated config is loaded.
      - RATE_LIMIT_SHORT_TTL=${RATE_LIMIT_SHORT_TTL:-}
      - RATE_LIMIT_SHORT_LIMIT=${RATE_LIMIT_SHORT_LIMIT:-}
      - RATE_LIMIT_MEDIUM_TTL=${RATE_LIMIT_MEDIUM_TTL:-}
      - RATE_LIMIT_MEDIUM_LIMIT=${RATE_LIMIT_MEDIUM_LIMIT:-}
      - RATE_LIMIT_LONG_TTL=${RATE_LIMIT_LONG_TTL:-}
      - RATE_LIMIT_LONG_LIMIT=${RATE_LIMIT_LONG_LIMIT:-}
      # Max request body size. Base64 media sends ride in the JSON body, so the default is generous;
      # raise this for large documents (e.g. BODY_SIZE_LIMIT=50mb). Blank keeps the app default of 25mb.
      - BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-}
      # Plugins
      - PLUGINS_DIR=${PLUGINS_DIR:-/app/data/plugins}
      - PLUGIN_STATE_DIR=${PLUGIN_STATE_DIR:-}
      # Plugin install-from-URL redirect policy (ssrf-guard): off unless the exact string 'true'.
      - PLUGIN_DOWNLOAD_ALLOW_INSECURE_REDIRECTS=${PLUGIN_DOWNLOAD_ALLOW_INSECURE_REDIRECTS:-false}
      # Security
      - API_MASTER_KEY=${API_MASTER_KEY:-}
      - TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
      # Forward the plain-HTTP dashboard CSP override; unset preserves the application default.
      - CSP_UPGRADE_INSECURE_REQUESTS=${CSP_UPGRADE_INSECURE_REQUESTS:-}
      # Security / boot knobs the app reads. This list is the only path into the container, so a
      # knob without a line here is inert no matter what the operator's .env says; compose-parity
      # .spec.ts derives the required set from .env.example so a new knob cannot ship unreachable.
      - API_KEY_PEPPER=${API_KEY_PEPPER:-}
      - BOOTSTRAP_KEY_FILE=${BOOTSTRAP_KEY_FILE:-}
      - ENABLE_SWAGGER=${ENABLE_SWAGGER:-}
      - VALIDATION_ERROR_DETAIL=${VALIDATION_ERROR_DETAIL:-}
      - MCP_READONLY=${MCP_READONLY:-}
      - MCP_RATE_LIMIT_MAX=${MCP_RATE_LIMIT_MAX:-}
      - MCP_RATE_LIMIT_WINDOW_MS=${MCP_RATE_LIMIT_WINDOW_MS:-}
      - MCP_IP_RATE_LIMIT_MAX=${MCP_IP_RATE_LIMIT_MAX:-}
      - MCP_IP_RATE_LIMIT_WINDOW_MS=${MCP_IP_RATE_LIMIT_WINDOW_MS:-}
      - METRICS_TOKEN=${METRICS_TOKEN:-}
      - CORS_ORIGINS=${CORS_ORIGINS:-}
      - SSRF_ALLOWED_HOSTS=${SSRF_ALLOWED_HOSTS:-}
      - SSRF_DNS_TIMEOUT_MS=${SSRF_DNS_TIMEOUT_MS:-}
      - WEBHOOK_SSRF_REDIRECTS=${WEBHOOK_SSRF_REDIRECTS:-}
      # HTTP server tuning (timeouts are relevant behind a proxy that holds connections).
      - REQUEST_TIMEOUT_MS=${REQUEST_TIMEOUT_MS:-}
      - HEADERS_TIMEOUT_MS=${HEADERS_TIMEOUT_MS:-}
      - KEEPALIVE_TIMEOUT_MS=${KEEPALIVE_TIMEOUT_MS:-}
      - INFLIGHT_BODY_BUDGET_BYTES=${INFLIGHT_BODY_BUDGET_BYTES:-}
      # Webhook delivery tuning and retention.
      - WEBHOOK_SHUTDOWN_DRAIN_MS=${WEBHOOK_SHUTDOWN_DRAIN_MS:-}
      - WEBHOOK_MEDIA_INLINE_MAX_BYTES=${WEBHOOK_MEDIA_INLINE_MAX_BYTES:-}
      - WEBHOOK_MAX_PAYLOAD_BYTES=${WEBHOOK_MAX_PAYLOAD_BYTES:-}
      - WEBHOOK_MAX_PER_SESSION=${WEBHOOK_MAX_PER_SESSION:-}
      - WEBHOOK_FAILURE_RETENTION_DAYS=${WEBHOOK_FAILURE_RETENTION_DAYS:-}
      - WEBHOOK_FAILURE_PAYLOAD_RETENTION_HOURS=${WEBHOOK_FAILURE_PAYLOAD_RETENTION_HOURS:-}
      - WEBHOOK_WORKER_CONCURRENCY=${WEBHOOK_WORKER_CONCURRENCY:-}
      - WEBHOOK_DEGRADED_SESSION_CONCURRENCY=${WEBHOOK_DEGRADED_SESSION_CONCURRENCY:-}
      # Integration Fabric ingress: per-instance fairness, retries, retention, reconciler, workers.
      - INGRESS_INSTANCE_LIMIT=${INGRESS_INSTANCE_LIMIT:-}
      - INGRESS_INSTANCE_TTL=${INGRESS_INSTANCE_TTL:-}
      - INGRESS_IP_LIMIT=${INGRESS_IP_LIMIT:-}
      - INGRESS_MAX_ATTEMPTS=${INGRESS_MAX_ATTEMPTS:-}
      - INGRESS_RETRY_DELAY_MS=${INGRESS_RETRY_DELAY_MS:-}
      - INGRESS_DEDUP_RETENTION_DAYS=${INGRESS_DEDUP_RETENTION_DAYS:-}
      - INGRESS_RETENTION_DAYS=${INGRESS_RETENTION_DAYS:-}
      - INGRESS_TIMESTAMP_TOLERANCE_SEC=${INGRESS_TIMESTAMP_TOLERANCE_SEC:-}
      - INGRESS_RECONCILE_INTERVAL_MS=${INGRESS_RECONCILE_INTERVAL_MS:-}
      - INGRESS_RECONCILE_GRACE_MS=${INGRESS_RECONCILE_GRACE_MS:-}
      - INGRESS_RECONCILE_BATCH_SIZE=${INGRESS_RECONCILE_BATCH_SIZE:-}
      - INGRESS_RECONCILE_MAX_ATTEMPTS=${INGRESS_RECONCILE_MAX_ATTEMPTS:-}
      - WEBHOOK_RECONCILE_INTERVAL_MS=${WEBHOOK_RECONCILE_INTERVAL_MS:-}
      - WEBHOOK_RECONCILE_GRACE_MS=${WEBHOOK_RECONCILE_GRACE_MS:-}
      - WEBHOOK_RECONCILE_BATCH_SIZE=${WEBHOOK_RECONCILE_BATCH_SIZE:-}
      - WEBHOOK_RECONCILE_MAX_ATTEMPTS=${WEBHOOK_RECONCILE_MAX_ATTEMPTS:-}
      - WEBHOOK_OUTBOX_RETENTION_DAYS=${WEBHOOK_OUTBOX_RETENTION_DAYS:-}
      - INGRESS_WORKER_CONCURRENCY=${INGRESS_WORKER_CONCURRENCY:-}
      # WebSocket gateway limits (frames bypass the HTTP guard pipeline).
      - WS_RATE_LIMIT_FRAME_PER_SECOND=${WS_RATE_LIMIT_FRAME_PER_SECOND:-}
      - WS_RATE_LIMIT_FRAME_BURST=${WS_RATE_LIMIT_FRAME_BURST:-}
      - WS_RATE_LIMIT_HANDSHAKE_MAX=${WS_RATE_LIMIT_HANDSHAKE_MAX:-}
      - WS_RATE_LIMIT_HANDSHAKE_WINDOW_MS=${WS_RATE_LIMIT_HANDSHAKE_WINDOW_MS:-}
      - WS_MAX_SOCKETS_PER_KEY=${WS_MAX_SOCKETS_PER_KEY:-}
      # Database / Redis connection tuning (selection keys are forwarded above).
      - DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE:-}
      - DATABASE_STATEMENT_TIMEOUT_MS=${DATABASE_STATEMENT_TIMEOUT_MS:-}
      - DATABASE_IDLE_TIMEOUT_MS=${DATABASE_IDLE_TIMEOUT_MS:-}
      - DATABASE_CONNECTION_TIMEOUT_MS=${DATABASE_CONNECTION_TIMEOUT_MS:-}
      - MAIN_DATABASE_NAME=${MAIN_DATABASE_NAME:-}
      - REDIS_USERNAME=${REDIS_USERNAME:-}
      - REDIS_PASSWORD=${REDIS_PASSWORD:-}
      - REDIS_CONNECT_TIMEOUT_MS=${REDIS_CONNECT_TIMEOUT_MS:-}
      - REDIS_TLS=${REDIS_TLS:-}
      - REDIS_CACHE_DB=${REDIS_CACHE_DB:-}
      - QUEUE_PREFIX=${QUEUE_PREFIX:-}
      # Engine / session tuning.
      - MAX_CONCURRENT_SESSIONS=${MAX_CONCURRENT_SESSIONS:-}
      - BAILEYS_AUTH_DIR=${BAILEYS_AUTH_DIR:-}
      - BAILEYS_BROWSER_NAME=${BAILEYS_BROWSER_NAME:-}
      - BAILEYS_LOG_LEVEL=${BAILEYS_LOG_LEVEL:-}
      - BAILEYS_MESSAGE_STORE_LIMIT=${BAILEYS_MESSAGE_STORE_LIMIT:-}
      - BAILEYS_SESSION_STORE_MAX_ENTRIES=${BAILEYS_SESSION_STORE_MAX_ENTRIES:-}
      - BAILEYS_CHAT_STATE_CACHE_MAX=${BAILEYS_CHAT_STATE_CACHE_MAX:-}
      - BAILEYS_GROUP_METADATA_CACHE_TTL_MS=${BAILEYS_GROUP_METADATA_CACHE_TTL_MS:-}
      - WWEBJS_ONBOARDING_CONTINUE_LABELS=${WWEBJS_ONBOARDING_CONTINUE_LABELS:-}
      # Default matches the image's ENV (Dockerfile); a blank forward would shadow it and make
      # puppeteer fall back to a bundled Chromium the image does not ship. compose-parity.spec.ts
      # asserts the default stays in sync with the Dockerfile value.
      - PUPPETEER_EXECUTABLE_PATH=${PUPPETEER_EXECUTABLE_PATH:-/usr/local/bin/puppeteer-chrome}
      - SHUTDOWN_DELAY_MS=${SHUTDOWN_DELAY_MS:-}
      - SIMULATE_TYPING_MAX_MS=${SIMULATE_TYPING_MAX_MS:-}
      - LID_MAPPING_CACHE_MAX=${LID_MAPPING_CACHE_MAX:-}
      # Media / storage budgets and sweeps.
      - EXPORT_INLINE_MEDIA_BUDGET_BYTES=${EXPORT_INLINE_MEDIA_BUDGET_BYTES:-}
      - CHAT_HISTORY_MEDIA_BUDGET_BYTES=${CHAT_HISTORY_MEDIA_BUDGET_BYTES:-}
      - STATUS_MEDIA_MAX_BYTES=${STATUS_MEDIA_MAX_BYTES:-}
      - STATUS_ORPHAN_SWEEP_INTERVAL_MS=${STATUS_ORPHAN_SWEEP_INTERVAL_MS:-}
      - STATUS_ORPHAN_GRACE_MS=${STATUS_ORPHAN_GRACE_MS:-}
      - STORAGE_IMPORT_MAX_BYTES=${STORAGE_IMPORT_MAX_BYTES:-}
      - STORAGE_IMPORT_MAX_ENTRIES=${STORAGE_IMPORT_MAX_ENTRIES:-}
      - STORAGE_IMPORT_MAX_TOTAL_BYTES=${STORAGE_IMPORT_MAX_TOTAL_BYTES:-}
      - STORAGE_EXPORT_TTL_MS=${STORAGE_EXPORT_TTL_MS:-}
      - STORAGE_EXPORT_SWEEP_MAX_AGE_MS=${STORAGE_EXPORT_SWEEP_MAX_AGE_MS:-}
      - S3_REPROBE_INTERVAL_MS=${S3_REPROBE_INTERVAL_MS:-}
      - MESSAGE_REAPER_INTERVAL_MS=${MESSAGE_REAPER_INTERVAL_MS:-}
      - MESSAGE_REAPER_GRACE_MS=${MESSAGE_REAPER_GRACE_MS:-}
      - MESSAGE_REAPER_BATCH_SIZE=${MESSAGE_REAPER_BATCH_SIZE:-}
      # Plugin sandbox limits and the install trust knobs.
      - PLUGIN_DOWNLOAD_MAX_BYTES=${PLUGIN_DOWNLOAD_MAX_BYTES:-}
      - PLUGIN_INSTALL_REQUIRE_PIN=${PLUGIN_INSTALL_REQUIRE_PIN:-}
      - PLUGIN_CATALOG_URL=${PLUGIN_CATALOG_URL:-}
      - PLUGIN_CAP_TIMEOUT_MS=${PLUGIN_CAP_TIMEOUT_MS:-}
      - PLUGIN_STORAGE_MAX_BYTES=${PLUGIN_STORAGE_MAX_BYTES:-}
      # The dashboard's update notice asks GitHub for the latest release; false turns that request off.
      - UPDATE_CHECK_ENABLED=${UPDATE_CHECK_ENABLED:-}
      # Misc application tuning.
      - AUDIT_RETENTION_DAYS=${AUDIT_RETENTION_DAYS:-}
      - MESSAGE_RETENTION_DAYS=${MESSAGE_RETENTION_DAYS:-}
      - BULK_MAX_CONCURRENT_BATCHES=${BULK_MAX_CONCURRENT_BATCHES:-}
      - TEMPLATE_RENDER_MAX_CHARS=${TEMPLATE_RENDER_MAX_CHARS:-}
      - STATS_CACHE_TTL_MS=${STATS_CACHE_TTL_MS:-}
      - SEARCH_PROVIDER=${SEARCH_PROVIDER:-}
      - SEARCH_LIMIT_MAX=${SEARCH_LIMIT_MAX:-}
      - LOG_FORMAT=${LOG_FORMAT:-}
      - BASE_URL=${BASE_URL:-}
      - DASHBOARD_URL=${DASHBOARD_URL:-}
      # Docker socket proxy (openwa-api never touches /var/run/docker.sock directly)
      - DOCKER_HOST=tcp://docker-proxy:2375
    volumes:
      - openwa-data:/app/data
    depends_on:
      docker-proxy:
        condition: service_started
        # Not required: lets operators disable the proxy (see the docker-proxy env comment)
        # without breaking `docker compose up`. DockerService degrades gracefully without it.
        required: false
      postgres:
        condition: service_healthy
        required: false
      redis:
        condition: service_healthy
        required: false
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:2785/api/health/ready']
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
    labels:
      - 'com.openwa.service=api'
      - 'com.openwa.core=true'

  # The dashboard SPA is now bundled into the openwa-api image and served by NestJS on the
  # same port (2785) — there is no separate dashboard container. Reach it at the openwa-api
  # port directly; put your own TLS reverse proxy (nginx/Caddy/cloud LB) in front if needed.

  # ===== OPTIONAL: Built-in PostgreSQL =====
  postgres:
    image: postgres:16-alpine
    container_name: openwa-postgres
    profiles: ['postgres', 'full']
    restart: unless-stopped
    networks:
      - openwa-network
    security_opt:
      - 'no-new-privileges:true'
    environment:
      POSTGRES_USER: ${DATABASE_USERNAME:-openwa}
      # No committed default secret. Empty unless the operator sets it; the postgres
      # image refuses to initialize with an empty password (clear fail-fast), and the app's
      # production boot guard rejects empty/placeholder secrets before startup.
      POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-}
      POSTGRES_DB: ${DATABASE_NAME:-openwa}
      # PostgreSQL schema for OpenWA's tables + migration ledger. Default 'public'. The init
      # script below creates the schema (if non-public) and sets the database default search_path,
      # so the built-in container works zero-config with a custom schema. Ignored when 'public'.
      POSTGRES_SCHEMA: ${POSTGRES_SCHEMA:-public}
    volumes:
      - postgres-data:/var/lib/postgresql/data
      # Runs only on first init (postgres image docker-entrypoint-initdb.d semantics): creates the
      # configured schema and sets the DB default search_path. No-op for 'public'; does not re-run
      # on an already-initialized volume (see the note in scripts/postgres-init-schema.sh).
      - ./scripts/postgres-init-schema.sh:/docker-entrypoint-initdb.d/01-create-schema.sh:ro
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${DATABASE_USERNAME:-openwa}']
      interval: 5s
      timeout: 3s
      retries: 5
    labels:
      - 'com.openwa.service=database'
      - 'com.openwa.builtin=true'

  # ===== OPTIONAL: Built-in Redis =====
  redis:
    image: redis:7-alpine
    container_name: openwa-redis
    profiles: ['redis', 'full']
    restart: unless-stopped
    networks:
      - openwa-network
    security_opt:
      - 'no-new-privileges:true'
    # noeviction is required by BullMQ: under any other policy Redis may evict queue keys once
    # maxmemory is reached, silently dropping queued jobs.
    command: redis-server --appendonly yes --maxmemory-policy noeviction
    volumes:
      - redis-data:/data
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 5s
      timeout: 3s
      retries: 5
    labels:
      - 'com.openwa.service=cache'
      - 'com.openwa.builtin=true'

  # ===== OPTIONAL: Built-in S3-compatible storage (pgsty/silo, a maintained MinIO fork) =====
  # Pinned by release tag and digest. Same server command, MINIO_ROOT_* variables, /data layout and
  # health route as MinIO, so an existing openwa_minio-data volume is used as-is. The service,
  # container and host name stay `minio`.
  minio:
    image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z@sha256:635197cb9f36d01bee221d34d1c7d7960f6a95c48b0b6c01d99cd13bdae51a46
    container_name: openwa-minio
    profiles: ['minio', 'full']
    restart: unless-stopped
    networks:
      - openwa-network
    security_opt:
      - 'no-new-privileges:true'
    command: server /data --console-address ":9001"
    environment:
      # No committed default credentials. With both left empty the server would start on its own
      # built-in default pair, so the password falls back to a placeholder shorter than the
      # 8-character minimum: every combination without a real secret stops at startup with a fatal
      # credential error (with a user set and no secret it reads "Invalid credentials"). The user
      # default stays empty on purpose: a non-empty placeholder would be a valid access key.
      # Canonical names, matching what the app/dashboard read (legacy S3_ACCESS_KEY/S3_SECRET_KEY
      # still accepted as a fallback so older .env files keep working).
      MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-${S3_ACCESS_KEY:-}}
      MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-${S3_SECRET_KEY:-unset}}
    volumes:
      - minio-data:/data
    ports:
      - '127.0.0.1:9000:9000'
      - '127.0.0.1:9001:9001'
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
      interval: 10s
      timeout: 5s
      retries: 3
    labels:
      - 'com.openwa.service=storage'
      - 'com.openwa.builtin=true'

volumes:
  # Pin explicit volume names so the compose path and the Docker-API orchestration path (which uses
  # the literal `openwa_<svc>-data` names in docker.service.ts) bind the SAME volume regardless of the
  # compose project name — otherwise re-enabling a built-in service could mount a fresh empty volume.
  openwa-data:
    name: openwa_openwa-data
    driver: local
  postgres-data:
    name: openwa_postgres-data
    driver: local
  redis-data:
    name: openwa_redis-data
    driver: local
  minio-data:
    name: openwa_minio-data
    driver: local

networks:
  # Application network — datastores and openwa-api. Keep the name `openwa-network`:
  # docker.service.ts attaches orchestrated containers to it by literal name.
  openwa-network:
    name: openwa-network
  # Isolated network for the Docker socket proxy. `internal: true` means no external
  # connectivity; only openwa-api joins it, so nothing else can reach docker-proxy:2375.
  # Plugins run inside openwa-api, so a loaded plugin can reach it too (see SECURITY.md).
  internal-docker:
    name: openwa-internal-docker
    internal: true

This file may be out of date. Check the OpenWA website for current setup steps.

Track your self-hosted stack

Bookmark software to try, rate tools you've used, and keep your collection in one place.

Metadata extracted from README on Oct 2, 2026

Alternatives to OpenWA

Dittofeed logo

Dittofeed

3.0k
Dittofeed is an open-source omni-channel customer engagement platform for sending broadcasts and automated user journeys across channels like email, SMS, WhatsApp, Slack, and mobile push.
Automation ToolsMailing ListsCRM Systems+3
Open Core
Details
Novu logo

Novu

40.2k
Novu is an open-source communication infrastructure platform for sending and managing multi-channel notifications and agent conversations through a single API.
Custom CommunicationsFOSS with IssuesPermissive License+2
6 ContainersFOSS with Issues
Details

Related Software

Chatwoot logo

Chatwoot

38.6k
An open-source, self-hosted customer support platform for managing conversations across channels.
Custom CommunicationsSelf Hosting SolutionsTicketing Systems+2
4 Containers
Details
Tiledesk logo

Tiledesk

325
Tiledesk is an open source live chat and customer support platform with integrated chatbots and multi-channel messaging.
Custom CommunicationsTicketing SystemsCRM Systems+4
Open Core
Details
Typebot logo

Typebot

10.6k
Typebot is a visual chatbot builder for creating advanced chatbots, embedding them in web or mobile apps, and collecting results in real time.
Custom CommunicationsWebsite BuildersLow Code Development+4
Details
Live Helper Chat logo

Live Helper Chat

2.3k
Live Helper Chat is an open-source live support and customer chat application for websites.
Custom CommunicationsTicketing SystemsVideo Conferencing+4
Details
Tailchat logo

Tailchat

3.6k
Tailchat is a self-hostable next-generation noIM application platform for team collaboration in an own workspace, with a plugin system and microservice architecture.
Custom CommunicationsSelf Hosting SolutionsPermissive License
Details
ntfy logo

ntfy

35.2k
ntfy is a simple HTTP-based pub-sub notification service for sending push notifications to phones or desktops via scripts and PUT/POST requests.
Custom CommunicationsPermissive LicenseCopyleft License+2
Open Core
Details
Appwrite logo

Appwrite

57.8k
Appwrite is an open-source development platform that provides backend infrastructure, authentication, databases, storage, functions, messaging, and web hosting in one place.
Low Code DevelopmentPermissive LicenseSelf Hosting Solutions+4
36 Containers
Details
larasend logo

larasend

493
Larasend is a self-hosted transactional email platform for Laravel teams that provides an HTTP API, dashboard, API keys, and a Laravel mail transport while sending through Amazon SES.
Mail Delivery AgentsSelf Hosting SolutionsAPI Management
5 Containers
Details
PushBits logo

PushBits

371
PushBits is a relay server for push notifications that sends messages via a simple web API and delivers them to users through Matrix.
Custom CommunicationsPermissive LicenseSelf Hosting Solutions+1
Details
convoy logo

convoy

2.9k
Convoy is an open source webhooks gateway for securely ingesting, persisting, debugging, delivering, and managing webhook events at scale.
API ManagementAutomation ToolsOpen Core
3 ContainersOpen CoreOpen Core
Details
Stoat logo

Stoat

2.7k
Stoat Self-Hosted provides Docker-based deployment instructions for running a full self-hosted Stoat instance, including backend, web front-end, file server, and proxy services.
Custom CommunicationsSocial NetworksFile & Object Storage+4
Details
shoutrrr logo

shoutrrr

473
Shoutrrr is a self-hostable social media scheduling tool for drafting, queuing, and publishing posts across multiple platforms from one workspace.
Social MediaAutomation ToolsScheduling and Bookings+2
Single Container
Details