Ctrl K
ComunidadGuías…

docker-compose.prod.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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
    # <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}
    # 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