File size: 15,956 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# 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.4.2
    container_name: openwa-docker-proxy
    restart: unless-stopped
    # Only on the isolated internal network β€” reachable solely by openwa-api, 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:
      CONTAINERS: 1
      IMAGES: 1
      VOLUMES: 1
      INFO: 1
      PING: 1
      POST: 1
      DELETE: 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.
    # NOTE: requires a live single-session smoke (Chromium must launch) before merge.
    security_opt:
      - 'no-new-privileges:true'
    cap_drop:
      - ALL
    cap_add:
      - CHOWN # entrypoint: chown -R 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}
      # 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:-}
      - 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:-}
      # Optional WhatsApp Web version override. Leave empty for whatsapp-web.js auto-selection.
      # If a session hangs at "authenticating", set WWEBJS_WEB_VERSION to a known-good cached build
      # from wppconnect-team/wa-version; latest|auto|off also forces auto-selection.
      - 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:-}
      # 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:-}
      # 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}
      # 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}
      # 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:-}
      # Docker socket proxy (openwa-api never touches /var/run/docker.sock directly)
      - DOCKER_HOST=tcp://docker-proxy:2375
    volumes:
      - openwa-data:/app/data
      - ./docker-compose.yml:/app/docker-compose.yml:ro
    depends_on:
      docker-proxy:
        condition: service_started
      postgres:
        condition: service_healthy
        required: false
      redis:
        condition: service_healthy
        required: false
    healthcheck:
      test:
        [
          'CMD',
          'node',
          '-e',
          "require('http').get('http://localhost:2785/api/health/ready', (r) => process.exit(r.statusCode === 200 ? 0 : 1))",
        ]
      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}
      # M16: 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'
    command: redis-server --appendonly yes
    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 MinIO (S3-compatible) =====
  minio:
    image: minio/minio:RELEASE.2025-09-07T16-13-09Z
    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:
      # M16: no committed default creds. MinIO refuses to start with empty root creds
      # (clear fail-fast); the app's production boot guard rejects empty/placeholder secrets.
      # 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:-}}
    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.
  internal-docker:
    name: openwa-internal-docker
    internal: true