mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Do @IanCouto. WHATSAPP_RESTART_ALL_SESSIONS nos dois composes + watchdog do worker religando sessão STOPPED (nunca FAILED, nunca SCAN_QR_CODE — a distinção é dele e é o que faz a regra conviver com a decisão oposta escrita em channel-health). Triagem: archived_at no SQL cru virou to_jsonb (provado num Postgres 17 descartável, com e sem a coluna), a var virou sobrescrivivel pelo .env e passou a ser gravada pelo install.sh (o test:shell pegou a promessa sem entrega), o log parou de repetir 1440x/dia, e o comentario do vigia irmao foi reconciliado. Fica a pergunta em aberto: o religamento nao tem teto nem backoff.
255 lines
12 KiB
YAML
255 lines
12 KiB
YAML
# 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]
|
|
|
|
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:
|
|
caddy-data:
|
|
caddy-config:
|
|
|
|
networks:
|
|
internal:
|
|
driver: bridge
|