Repository navigation
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
558 lines (550 loc) · 31.6 KB
/
Copy pathdocker-compose.yml
File metadata and controls
558 lines (550 loc) · 31.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
# 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 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:-}
# 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_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