# 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. # O teto vem do `.env` (APP_MEM_LIMIT), e sem a chave fica o de sempre. Numa # VPS maior, suba-o lá — nunca aqui: este arquivo é do clone, e uma edição à # mão faz o `git checkout` do update.sh recusar a versão nova que também o # mudou. mem_limit: ${APP_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 # 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} # SEM `build:` AO LADO, de propósito — e é assim que o portão do update.sh # é consultado. Medido no Compose: com `image:` + `build:` no mesmo bloco, # o `up -d` CONSTRÓI em QUALQUER falha de pull (tag inexistente, registro # fora por DNS, "no matching manifest"), seja o `pull_policy` `always`, # `missing` ou sem a chave. Aqui isso seria o pior desfecho possível: o app é # o único build pesado da stack, e o portão `build_local_permitido` (#1955) # — que RECUSA construir quando o registro não responde, depois do apagão de # DNS que virou OOM no `next-build` e o CRM em 502 — só é consultado quando # o `up -d` FALHA. Sem `build:` aqui, a falta da imagem do app é o que faz o # `up -d` falhar e entrega a decisão ao portão; quem o Compose constrói # sozinho é worker, scheduler e voice-agent, cujo build é barato (pnpm # install e apk add) — o escape deles continua. # # Quem QUER construir o app usa o docker-compose.build.yml # (`docker compose -f docker-compose.prod.yml -f docker-compose.build.yml # build`), que passa `APP_VERSION` como argumento — por este `build:` o app # sairia com o `APP_VERSION=dev` que está no Dockerfile. # # `image:` continua obrigatório (invariante 1 da doutrina de packaging): # um serviço build-only é pulado pelo pull e NUNCA é atualizado. Aqui a # imagem continua vindo do registro. restart: unless-stopped env_file: .env environment: SUPABASE_DB_ADMIN_URL: "" # O `worker` deste arquivo drena o event_log em poucos segundos (2 s com # fila, até 10 s parado); o webhook de mensagem não precisa mais drenar a # fila de todas as organizações dentro da requisição # (lib/dev/kick-local-pipeline.ts). EVENT_LOG_WORKER_DRAINS: ${EVENT_LOG_WORKER_DRAINS:-true} 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))"] # A cada 10 s, e não a cada 30 (conserto de @jmpo, PR #3393). Atrás do # Traefik da hospedagem (Hostinger, Coolify, Dokploy — o # docker-compose.traefik.yml), o tráfego só vai a contêiner `healthy`; o # app fica pronto em ~5 s, mas com Docker 24 ou mais antigo a primeira # checagem só rodava aos 30 s, e a atualização deixava o domínio em # "404 page not found" por 30–45 s (medido numa instalação real em # 24/09/2026, quatro deploys no dia). # Com Docker 25 ou mais novo a janela já era de ~5 s e não muda. O Caddy # padrão do kit não consulta a saúde para rotear. # `retries` sobe na mesma proporção: 10 s × 15 = os mesmos 150 s de # tolerância de antes (30 s × 5) para declarar o app doente. # # NÃO trocar por `start_interval` (que pergunta rápido só durante o # `start_period`): o Compose 2.17 RECUSA o arquivo inteiro com # "Additional property start_interval is not allowed", e o kit só exige # "docker compose v2" — o `up -d` de quem tem Compose antigo morreria. # Medido: 2.17.2 recusa; 2.21.0 e 5.5.1 com motor de API 1.43 (Docker # 24) aceitam e ignoram calados. Vigiado em # tests/unit/healthcheck-do-app-libera-o-trafego-cedo.test.ts. interval: 10s timeout: 5s retries: 15 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. # O teto vem do `.env` (WORKER_MEM_LIMIT), e sem a chave fica o de sempre. Numa # VPS maior, suba-o lá — nunca aqui: este arquivo é do clone, e uma edição à # mão faz o `git checkout` do update.sh recusar a versão nova que também o # mudou. mem_limit: ${WORKER_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 # ═══ O PRAZO QUE FALTAVA (#2823) ═══ # # No `docker stop` o Docker manda SIGTERM e espera ESTE prazo; passado ele, # manda SIGKILL. Sem a chave, o prazo é o PADRÃO DE 10 s — e o worker se dá # 30 s (`SHUTDOWN_GRACE_MS`) para fechar laços + drain + pool. O SIGKILL # chegava, portanto, na METADE do prazo que o próprio worker pediu, e o job # em voo voltava `running` até o reaper (10 min de invisibilidade a cada # `docker stop` — o defeito da issue, medido em 19,6 s de parada ociosa com # o laço do `event_log` preso numa chamada de rede). # # ═══ POR QUE O NÚMERO NÃO RESOLVE A ISSUE SOZINHO ═══ # # Aumentar o prazo do supervisor é o remédio que a issue #2823 PROÍBE como # conserto único (o comentário do `Dockerfile.worker` registra isso): com o # prazo armado depois de esperar os laços, o `stop_grace_period` que faltava # cobriria uma janela que começava tarde — os 19,6 s do laço preso ficavam # por fora de qualquer prazo, interno ou externo. O par só funciona junto: # o worker conta o prazo DO SINAL (`workers/agent-worker/desligamento-no-prazo.ts`) # e este número é o teto de fora. # # ═══ A REGRA DO PAR ═══ # # `stop_grace_period` > `SHUTDOWN_GRACE_MS`/1000, com folga para o fecho do # pool e do healthz. O default de 40 s para os 30 s internos dá 10 s de # folga. Os dois são gravados juntos pelo `install.sh` (kit) e o par que não # fecha é reprovado por `workers/agent-worker/prazo-do-shutdown-comeca-no-sinal.test.ts`. stop_grace_period: ${WORKER_STOP_GRACE_PERIOD:-40s} 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. # O teto vem do `.env` (WAHA_MEM_LIMIT), e sem a chave fica o de sempre. Numa # VPS maior, suba-o lá — nunca aqui: este arquivo é do clone, e uma edição à # mão faz o `git checkout` do update.sh recusar a versão nova que também o # mudou. mem_limit: ${WAHA_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}" # Fixo na rede interna, como o WAHA_API_BASE_URL do worker: o WAHA desta # stack mora ao lado do `app`, e a rota global só atende a rede interna # (app/api/v1/webhooks/waha/route.ts). Lido do .env, um endereço público # ali faria a entrega parar sem nenhum arquivo ter mudado. WHATSAPP_HOOK_URL: http://app:3000/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. # `message.reaction` entra: é por onde chega o 👍/😐 do cliente (#2797). # O WAHA trata reação como evento PRÓPRIO — sem assiná-lo, um gesto que o # CRM recebe pela API oficial nunca chegaria pelo canal por QR. `message.any` # NÃO o cobre (a reação não vem como mensagem). WHATSAPP_HOOK_EVENTS: "message.any,message.ack,message.edited,message.revoked,message.reaction,session.status,state.change" # Nome EXATO da doc do WAHA (events#hmac-authentication): WHATSAPP_HOOK_HMAC # não existe nele — sem o _KEY ele ignora e nunca assina. WHATSAPP_HOOK_HMAC_KEY: ${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 # Ponto de extensão (#2724): arquivos .caddy soltos nesta pasta local são # importados pelo fim do Caddyfile, e a pasta não é versionada — publicar # outro serviço no mesmo VPS não mexe em arquivo nenhum do repositório, # então nada trava o update.sh. Somente-leitura: o contêiner não escreve # em configuração que o operador mantém no host. - ./caddy-sites:/etc/caddy/sites:ro - caddy-data:/data # certificados Let's Encrypt (persistir!) - caddy-config:/config depends_on: app: condition: service_healthy logging: *default-logging # `caddy-sites` (#2724): rede só entre o Caddy e os serviços extras que o # operador publica por caddy-sites/. O serviço de terceiro entra nela, e # não na `internal`, para não alcançar os serviços internos do CRM. networks: [internal, caddy-sites] volumes: waha-data: waha-media: wacalls-data: asterisk-data: asterisk-logs: caddy-data: caddy-config: networks: internal: driver: bridge caddy-sites: driver: bridge