# 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), INFO (getSystemInfo), 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 INFO: 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 graceful drain room to complete before Docker SIGKILLs the container: the shutdown # grace (SHUTDOWN_DELAY_MS, default 3s) plus the worst-case per-engine teardown bound (~10s) can # exceed Docker's 10s default, which would otherwise kill Chromium mid-teardown and orphan a # session profile. Tune down if you raise neither SHUTDOWN_DELAY_MS nor run many sessions. stop_grace_period: 45s # 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 # ONLY for the root entrypoint's chown + gosu privilege-drop; 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 - 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:-} - 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_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:-} # 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:-} - 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_LIST_MAX_FILES=${STORAGE_LIST_MAX_FILES:-} - STORAGE_IMPORT_MAX_BYTES=${STORAGE_IMPORT_MAX_BYTES:-} - STORAGE_IMPORT_MAX_ENTRIES=${STORAGE_IMPORT_MAX_ENTRIES:-} - 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_-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