OpenWA
★ 16.4kA self-hosted alternative to Twilio
About
Community Ratings
No ratings yetNew this week
Read recap →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.
RailwayBest match
One click
Railway template, runs in your own Railway account.
About 5 minutes to running
Coolify
One click on your server
Coolify template for your own server.
About 10 minutes if you run Coolify
Hetzner
Full control
A small server, then Docker Compose.
About 20 minutes with Docker
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.
Tags
Track your self-hosted stack
Bookmark software to try, rate tools you've used, and keep your collection in one place.