Files
DeskcommCRM/docker-compose.prod.yml

435 lines
21 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
environment:
SUPABASE_DB_ADMIN_URL: ""
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
SUPABASE_DB_ADMIN_URL: ""
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 entregaria ao cliente qualquer versão que o upstream tivesse
# publicado. O instalador grava `latest-2026.7.2` em x86 e
# `noweb-arm-2026.7.2` em ARM64; as duas tags fixam a versão NOWEB.
# 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 — o menor passo que o crond tem, e o piso de latência
# de tudo que depende de cron (a lista tem rotas de minuto em minuto).
#
# 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]
# ═══ TELEFONIA POR SIP (#677) — MÓDULO OPCIONAL, DESLIGADO POR PADRÃO ═══
#
# Decisão do dono (doc 27 da triagem): a telefonia por SIP entra como módulo
# opcional do compose, no molde do `wacalls`. Os dois serviços abaixo só
# existem com `telefonia` em COMPOSE_PROFILES no .env — sem isso o compose
# nem os cria, e nenhuma instalação que não pediu ganha porta aberta,
# contêiner ou consumo de memória.
#
# As mesmas duas regras do `wacalls` valem aqui: nomear o serviço no
# `up -d` ATIVA o profile; e `app`/`worker` não podem ganhar `depends_on`
# nestes serviços (dependência de profile inativo derruba o CRM inteiro).
#
# Para ligar: copiar asterisk/pjsip.conf.example e asterisk/ari.conf.example
# para os nomes sem `.example` (com as credenciais do tronco e do ARI),
# pôr `telefonia` em COMPOSE_PROFILES e subir.
#
# ═══ POR QUE O ASTERISK É PINADO POR DIGEST ═══
#
# Asterisk é peça UPSTREAM — se referencia, nunca se republica
# (docs/doctrine/packaging.md). E neste registry nenhuma tag é imutável: a
# `20-alpine` e até as tags de versão (`20.20.1-alpine`) são reconstruídas
# a cada build do mantenedor (medido em 18/09/2026: a mesma `20.20.1` com
# digests diferentes em 19/08, 26/08, 02/09, 09/09 e 16/09). O digest abaixo
# é o da `20-alpine` = `20.20.1_alpine-3.24-e9d725f` (16/09), multi-arch
# (amd64 + arm64). `pull_policy: missing` porque o digest é imutável.
asterisk:
profiles: ["telefonia"]
mem_limit: 256m
image: ${ASTERISK_IMAGE:-andrius/asterisk@sha256:927d4c98777149c3380c4f8d0e82b1b1ba9e7b38528f86c1e1593d5a25032f27}
pull_policy: ${ASTERISK_PULL_POLICY:-missing}
restart: unless-stopped
ports:
# Só UDP, e pelo mesmo motivo do `wacalls`: sinalização SIP e mídia RTP
# não falam HTTP, não há proxy L7 que as carregue, e o tronco do
# provedor precisa alcançá-las de fora.
#
# NUNCA TCP: a ARI (REST + WebSocket, 8088) é o controle total das
# chamadas e fica só na rede interna, alcançável apenas pelo
# `voice-agent`. Publicá-la entregaria à internet quem liga para quem.
- "5060:5060/udp"
# Faixa de RTP casada com asterisk/rtp.conf — mudar uma sem a outra deixa
# a ligação muda com tudo "conectado" nos logs.
- "10000-10200:10000-10200/udp"
volumes:
- ./asterisk/pjsip.conf:/etc/asterisk/pjsip.conf:ro
- ./asterisk/extensions.conf:/etc/asterisk/extensions.conf:ro
- ./asterisk/ari.conf:/etc/asterisk/ari.conf:ro
- ./asterisk/http.conf:/etc/asterisk/http.conf:ro
- ./asterisk/rtp.conf:/etc/asterisk/rtp.conf:ro
- asterisk-data:/var/lib/asterisk
- asterisk-logs:/var/log/asterisk
healthcheck:
test: ["CMD", "asterisk", "-rx", "core show uptime"]
interval: 15s
timeout: 5s
retries: 5
logging: *default-logging
networks: [internal]
# O runtime de voz é NOSSO: imagem publicada pelo CI (publish-image.yml),
# nunca build-only (doutrina de packaging, invariante 1). `build:` fica ao
# lado só como escape.
voice-agent:
profiles: ["telefonia"]
mem_limit: 512m
image: ${VOICE_AGENT_IMAGE:-ghcr.io/melgarafael/deskcomm-voice-agent:stable}
pull_policy: ${VOICE_AGENT_PULL_POLICY:-always}
build:
context: .
dockerfile: Dockerfile.voice-agent
restart: unless-stopped
depends_on:
asterisk:
condition: service_healthy
# env_file completo, como o `worker`: lib/env.ts valida todas as variáveis
# da aplicação no import, não só as de voz.
env_file: .env
environment:
ARI_URL: "http://asterisk:8088"
ARI_WS_URL: "ws://asterisk:8088/ari/events"
ARI_APP: "voice-agent"
# Como no app e no worker: a conexão do dono do banco fica no .env só
# para o kit no host (update.sh do cron); nenhum código de workers/ a lê.
SUPABASE_DB_ADMIN_URL: ""
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:
asterisk-data:
asterisk-logs:
caddy-data:
caddy-config:
networks:
internal:
driver: bridge