-
Notifications
You must be signed in to change notification settings - Fork 591
Expand file tree
/
Copy pathdocker-compose.prod.yml
More file actions
342 lines (331 loc) · 16.6 KB
/
Copy pathdocker-compose.prod.yml
File metadata and controls
342 lines (331 loc) · 16.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
# DeskcommCRM — stack de produção self-host (VPS HostGator).
# Sobe: caddy (HTTPS) → app (Next standalone) + waha + redis+srh + scheduler.
# Só o Caddy publica portas (80/443). Todo o resto vive na rede interna.
#
# docker compose -f docker-compose.prod.yml --env-file .env up -d
x-logging: &default-logging
driver: json-file
options:
max-size: "10m" # rotação: evita que o log encha o disco do VPS
max-file: "3"
services:
app:
# Teto de memória — 335 MiB de pico MEDIDO nesta VPS.
#
# Sem ele o contêiner é ilimitado, e numa VPS de 3,8 GB isso não é teoria:
# um contêiner ALHEIO (agente da hospedagem, fora deste compose) registrou
# pico de 3.068 MiB no mesmo host. Quando a RAM acaba, o OOM killer escolhe
# a vítima por PONTUAÇÃO — quem cresceu mais rápido —, não por importância:
# o que morre pode ser o CRM.
#
# O teto não impede o pico legítimo; impede que UM serviço leve a máquina
# inteira. Estourá-lo mata só quem estourou, e o `restart: unless-stopped`
# o traz de volta — falha isolada em vez de queda geral.
mem_limit: 768m
# Imagem GENÉRICA pré-buildada: o leigo só puxa (sobe em ~2min, roda em 2GB).
# Os NEXT_PUBLIC_* reais vêm em RUNTIME (env_file abaixo) — o browser lê via
# <PublicEnvScript/> e o servidor via lib/env.ts. Não precisa buildar no VPS.
# Build local (avançado): docker compose -f docker-compose.prod.yml -f docker-compose.build.yml build
# `:stable` (última release), não `:latest` (topo da `main`). Quem cai neste
# default é quem NÃO tem APP_IMAGE no .env — avaliação, ou um .env que
# perdeu a chave. Nenhum desses casos quer código não lançado; quem quiser o
# topo da main pede `APP_IMAGE=…:latest` explicitamente.
image: ${APP_IMAGE:-ghcr.io/melgarafael/deskcommcrm:stable}
pull_policy: ${APP_PULL_POLICY:-always}
restart: unless-stopped
env_file: .env
depends_on:
srh:
condition: service_started
waha:
condition: service_started
healthcheck:
# Probe TCP puro (node existe na imagem; curl/nc não). NÃO usar /api/v1/health:
# ele dá 503 se WAHA/Redis caem, o que deixaria o app "unhealthy" e derrubaria
# o Caddy junto (blackout total por uma dependência secundária).
test: ["CMD", "node", "-e", "require('net').connect(3000,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
logging: *default-logging
networks: [internal]
# Worker 24/7 do agent-engine (fusão Vendaval) — cérebro do agente SDR.
worker:
# Teto de memória — 230 MiB de pico MEDIDO nesta VPS.
#
# Sem ele o contêiner é ilimitado, e numa VPS de 3,8 GB isso não é teoria:
# um contêiner ALHEIO (agente da hospedagem, fora deste compose) registrou
# pico de 3.068 MiB no mesmo host. Quando a RAM acaba, o OOM killer escolhe
# a vítima por PONTUAÇÃO — quem cresceu mais rápido —, não por importância:
# o que morre pode ser o CRM.
#
# O teto não impede o pico legítimo; impede que UM serviço leve a máquina
# inteira. Estourá-lo mata só quem estourou, e o `restart: unless-stopped`
# o traz de volta — falha isolada em vez de queda geral.
mem_limit: 512m
# Imagem publicada — e não mais `build:` sozinho. O motivo é o modo de falha
# que isso escondia: serviço build-only é PULADO por `docker compose pull`
# ("Skipped - No image to be pulled") e imune a `up -d` sem `--build`. Ele era
# construído na VPS de todo cliente, no install, e NENHUM update.sh jamais o
# reconstruía — o runtime do agente de IA congelava no código do dia da
# instalação e atravessava todas as atualizações seguintes.
#
# O `build:` continua abaixo de propósito: com os dois presentes, o Compose
# usa a imagem quando ela existe e CONSTRÓI quando não existe (medido). É o
# que mantém a mudança aditiva — registry fora do ar, ou versão anterior à
# primeira publicação, caem no comportamento antigo em vez de quebrar.
#
# O default é `:stable` (a última RELEASE), não `:latest`, e a diferença é
# concreta para o parque instalado. Numa VPS que atualiza pela primeira vez
# depois desta mudança, quem executa é o `update.sh` VELHO — o que está no
# disco —, e ele só sabe gravar APP_IMAGE. O `git checkout` da tag já trocou
# ESTE arquivo, então worker e scheduler caem no default daqui. Com
# `:latest`, esse default seria o TOPO DA MAIN (o canal segue a branch
# default): o app pinado numa release e o runtime do agente de
# IA rodando código não lançado, sobre o banco da release. `:stable` é a
# última release publicada — pareia com o app em vez de correr na frente.
image: ${WORKER_IMAGE:-ghcr.io/melgarafael/deskcomm-worker:stable}
pull_policy: ${WORKER_PULL_POLICY:-always}
build:
context: .
dockerfile: Dockerfile.worker
restart: unless-stopped
env_file: .env
environment:
# dentro da rede do compose, o WAHA é o serviço 'waha' na porta interna 3000
WAHA_API_BASE_URL: http://waha:3000
networks:
- internal
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8787/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
waha:
# Teto de memória — 893 MiB de pico MEDIDO nesta VPS.
#
# Sem ele o contêiner é ilimitado, e numa VPS de 3,8 GB isso não é teoria:
# um contêiner ALHEIO (agente da hospedagem, fora deste compose) registrou
# pico de 3.068 MiB no mesmo host. Quando a RAM acaba, o OOM killer escolhe
# a vítima por PONTUAÇÃO — quem cresceu mais rápido —, não por importância:
# o que morre pode ser o CRM.
#
# O teto não impede o pico legítimo; impede que UM serviço leve a máquina
# inteira. Estourá-lo mata só quem estourou, e o `restart: unless-stopped`
# o traz de volta — falha isolada em vez de queda geral.
mem_limit: 1280m
# Core grátis por padrão; troque para devlikeapro/waha-plus via WAHA_IMAGE se
# precisar de multi-número/retry/S3 (licença paga).
#
# Tag PINADA: `devlikeapro/waha` sem tag é `:latest`, e o `dc pull` do
# update.sh entregava ao cliente qualquer versão que o upstream tivesse
# publicado — inclusive uma nunca testada por nós, no meio de uma
# atualização. `latest-2026.7.2` é o mesmo digest de `latest` hoje
# (65e593e30bb7…), então o pin não muda um bit; muda quem escolhe quando
# o WhatsApp do cliente troca de motor. Bumpar faz parte do release.
image: ${WAHA_IMAGE:-devlikeapro/waha:latest-2026.7.2}
restart: unless-stopped
environment:
# Prefixo sha512: faz o WAHA HASHEAR o X-Api-Key recebido e comparar com este
# hash. Sem o prefixo ele compara literal → o app (que manda o plaintext) toma 401.
WAHA_API_KEY: "sha512:${WAHA_API_KEY_SHA512}"
WHATSAPP_HOOK_URL: ${WAHA_WEBHOOK_BASE_URL}/api/v1/webhooks/waha
# `message` NÃO entra, e `message.any` sozinho não perde nada — medido.
#
# O WAHA emite os DOIS para a mesma mensagem, e `lib/waha/ingest.ts` já
# chama isso de corrida e deduplica. Só que o arquivo do corpo cru guarda
# as duas cópias: numa instalação real, 22.939 linhas de `message` ao lado
# de 24.582 de `message.any` — 41% da tabela que era 86% do banco.
#
# A prova de que dá para tirar veio do banco, não da doc: cruzando os ids
# de mensagem dos dois eventos em 22.815 mensagens reais, os que chegaram
# SÓ por `message` foram ZERO, e 1.642 chegaram só por `message.any` (os
# que saíram de nós, que `message` não cobre). Superconjunto estrito.
WHATSAPP_HOOK_EVENTS: "message.any,message.ack,message.edited,message.revoked,session.status,state.change"
WHATSAPP_HOOK_HMAC: ${WAHA_HMAC_SECRET}
# WAHA lê WHATSAPP_DEFAULT_ENGINE (NÃO WAHA_DEFAULT_ENGINE — o nome errado
# fazia cair no default WEBJS, engine com browser que trava/corrompe a sessão
# quando o WhatsApp Web atualiza; NOWEB é websocket direto, estável e leve).
WHATSAPP_DEFAULT_ENGINE: ${WHATSAPP_DEFAULT_ENGINE:-NOWEB}
WAHA_DASHBOARD_ENABLED: "false" # nunca expor o dashboard num template público
# Sem isto o container volta e as sessões ficam STOPPED: o número continua
# pareado em /app/.sessions, mas nada entra nem sai até alguém clicar
# Reconectar. True retoma as que já existiam — não reloga, não pede QR.
# Default do WAHA é False.
WHATSAPP_RESTART_ALL_SESSIONS: "${WHATSAPP_RESTART_ALL_SESSIONS:-True}"
volumes:
- waha-data:/app/.sessions # sessões pareadas (QR) — perder = re-parear
- waha-media:/app/.media
logging: *default-logging
networks: [internal]
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"] # efêmero (rate-limit/debounce)
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 3s
retries: 3
logging: *default-logging
networks: [internal]
srh:
# serverless-redis-http: fala o REST do Upstash sobre o redis normal, mantendo
# o SDK @upstash/redis sem tocar código.
#
# Pinado por DIGEST, não por tag, e o motivo é específico: `latest` e `0.0.10`
# NÃO são a mesma imagem (5b0bb923… vs 65128347…), então trocar `:latest` por
# `:0.0.10` mudaria o binário que roda no parque — que é exatamente o risco
# que a pendência T6 mandava validar antes (encoding base64 do rate limit).
# O digest congela o que já está em produção HOJE: zero mudança de conteúdo,
# e o `:latest` deixa de poder mudar sozinho debaixo de todo mundo.
# Quando a T6 for validada, isto vira `:0.0.10` e o comentário sai.
image: hiett/serverless-redis-http@sha256:5b0bb9239fce53abf87b2018a7a0deb9ec7bd900c5360738fe5fbeeb426f9150
restart: unless-stopped
environment:
SRH_MODE: env
SRH_TOKEN: ${SRH_TOKEN}
SRH_CONNECTION_STRING: "redis://redis:6379"
depends_on:
redis:
condition: service_healthy
logging: *default-logging
networks: [internal]
scheduler:
# Cron sem docker.sock: crond interno batendo curl na rede interna.
# Resolução de 1 min (= paridade com os crons da Vercel).
#
# A lista de crons mora em docker/scheduler/entrypoint.sh, não mais aqui. O
# `command:` inline instalava curl e tzdata (`apk add`) a CADA start — num
# serviço `restart: unless-stopped`, isso significa que o cron do cliente só
# voltava se a VPS tivesse internet e o mirror do Alpine estivesse de pé,
# justamente no momento em que a máquina está se recuperando de algo.
image: ${SCHEDULER_IMAGE:-ghcr.io/melgarafael/deskcomm-scheduler:stable}
pull_policy: ${SCHEDULER_PULL_POLICY:-always}
build:
context: .
dockerfile: Dockerfile.scheduler
restart: unless-stopped
depends_on:
app:
condition: service_healthy
environment:
TZ: UTC
INTERNAL_SECRET: ${INTERNAL_SECRET}
logging: *default-logging
networks: [internal]
# ── Chamada de voz WhatsApp (spec 18) — DESLIGADA POR PADRÃO ──────────────
#
# ═══ POR QUE ELA VIVE NUM PROFILE ═══
#
# A chamada de voz vincula um SEGUNDO APARELHO ao mesmo número que já atende,
# por um caminho que não é o oficial: o risco é a CONTA ser bloqueada. Serviço
# que sobe em toda instalação transforma esse risco em padrão — e um contêiner
# a mais custa RAM numa VPS onde o WAHA já registrou 893 MiB de pico.
#
# `profiles:` é o mesmo mecanismo com que `docker-compose.traefik.yml` desliga
# o Caddy: sem o profile ativo, o compose NÃO cria o contêiner. Ligar é uma
# linha no .env — `COMPOSE_PROFILES=voz` — e nada mais.
#
# ⚠️ DUAS ARMADILHAS MEDIDAS, as duas do próprio Compose:
#
# 1. Nomear o serviço explicitamente (`docker compose up -d wacalls`) ATIVA
# o profile e sobe o contêiner mesmo com COMPOSE_PROFILES vazio. É por
# isso que o update.sh checa antes de recriar o caddy, e vale igual aqui.
# 2. `app` e `worker` NÃO podem ganhar `depends_on: wacalls`. Dependência
# em serviço de profile inativo não sobe — o CRM inteiro deixaria de
# subir em toda instalação que não usa a feature.
#
# ═══ POR QUE A IMAGEM É DO UPSTREAM, PINADA POR DIGEST ═══
#
# WaCalls (github.com/JotaDev66/WaCalls, MIT) é peça UPSTREAM, e a doutrina
# (docs/doctrine/packaging.md) é explícita: peça upstream se REFERENCIA, nunca
# se republica — "a regra vale para todas, não só a licenciada, porque a
# exceção é o que apaga a regra". Construir e publicar `deskcomm-wacalls`
# seria republicar.
#
# O digest é o pin mais forte que existe e o único disponível aqui: `:latest`
# e `:latest-pure` NÃO existem neste registry (404 medido em 2026-09-08); as
# tags publicadas são `develop` e `sha-*`. Ele resolve para
# `sha-d16a076`/`develop`, index OCI multi-arch (linux/amd64 + linux/arm64).
#
# `pull_policy: missing` porque o digest é imutável: com `always`, um GHCR
# fora do ar impediria o contêiner de subir mesmo com a imagem já no disco.
#
# ═══ SEM `ports:` — E ISSO É VIGIADO ═══
#
# A API do WaCalls fica só na rede interna, alcançável pelo app e pelo worker.
# `tests/unit/portas-do-compose.test.ts` reprova quem publicar porta aqui.
wacalls:
profiles: ["voz"]
mem_limit: 256m
image: ${WACALLS_IMAGE:-ghcr.io/jotadev66/wacalls@sha256:9ff52ef9455cf42125a911c38bb8c855f0cc205516cada2054e5c05546909022}
pull_policy: ${WACALLS_PULL_POLICY:-missing}
restart: unless-stopped
environment:
# O serviço NÃO SOBE sem estes dois — é o upstream que exige, e é o que
# substitui o build anterior, cujo README dizia "the API has no
# authentication ... run it only on a trusted LAN".
WACALLS_ADMIN_USER: ${WACALLS_ADMIN_USER:-}
WACALLS_ADMIN_PASSWORD: ${WACALLS_ADMIN_PASSWORD:-}
# Bearer da automação. Sem ele a API só aceita o cookie de login, que um
# processo server-to-server não tem: o app não conseguiria falar nada.
WACALLS_API_TOKEN: ${WACALLS_API_TOKEN:-}
# Vazio = same-origin. Nossa UI não fala com o WaCalls direto do browser —
# tudo passa pelas rotas /api/v1/voice/*, então não há origem a liberar.
WACALLS_CORS_ORIGINS: ${WACALLS_CORS_ORIGINS:-}
WACALLS_TRUSTED_PROXIES: ${WACALLS_TRUSTED_PROXIES:-}
WACALLS_PUBLIC_IP: ${WACALLS_PUBLIC_IP:-}
# A porta que o áudio usa, e ela precisa ser FIXA. Sem esta variável o
# upstream não monta o mux de UDP e cai em portas efêmeras aleatórias —
# impossíveis de publicar aqui, e o áudio nunca sai da máquina.
WACALLS_WEBRTC_UDP_PORT: ${WACALLS_WEBRTC_UDP_PORT:-7881}
ports:
# A ÚNICA porta publicada por um serviço nosso que não é o proxy, e ela é
# UDP. Não é exceção sem razão: o áudio do WebRTC não fala HTTP, então não
# há proxy que o carregue — o Caddy é L7 e não repassa UDP.
#
# host == contêiner de PROPÓSITO. O número entra no candidato SDP que o
# navegador recebe; um remap (7881:7882) faria o navegador mandar mídia
# para uma porta que o host não escuta, e a ligação ficaria muda com tudo
# "certo" nos logs.
#
# NUNCA TCP: a API HTTP do serviço continua só na rede interna, alcançável
# apenas pelo app e pelo worker. Publicar TCP aqui exporia a interface de
# administração dele à internet.
- "${WACALLS_WEBRTC_UDP_PORT:-7881}:${WACALLS_WEBRTC_UDP_PORT:-7881}/udp"
volumes:
# wacalls.db guarda a credencial de sessão do WhatsApp — mesmo padrão do
# waha-data. Apagar o volume desvincula todos os aparelhos.
- wacalls-data:/data
logging: *default-logging
networks: [internal]
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
DOMAIN: ${DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data # certificados Let's Encrypt (persistir!)
- caddy-config:/config
depends_on:
app:
condition: service_healthy
logging: *default-logging
networks: [internal]
volumes:
waha-data:
waha-media:
wacalls-data:
caddy-data:
caddy-config:
networks:
internal:
driver: bridge