The client base URL is the origin of every link the server hands to third parties. With SUPABASE_SERVER_URL filled, `signedUrl` and `data.url` came out as `http://kong:8000` — media, avatars, product photos, the LGPD PDF and the Google login pointed at an address those callers cannot reach. server.ts, admin.ts and proxy.ts now keep `NEXT_PUBLIC_SUPABASE_URL` as the base and pass `global.fetch = fetchDoServidor(interna, publica)`, a fetch that rewrites the public prefix to the internal one: requests (storage sign, rest/v1, auth/v1/user) take the short path while links stay public. Raw fetches (health, request-deps, agent worker) keep swapping the URL only — they generate no link. provedor-google and convite-no-gotrue now go through the same resolver. Adds lib/supabase/fetch-do-servidor.test.ts: with the variable filled, createSignedUrl and signInWithOAuth return the public origin and the request still goes to the internal one — red before this commit. The three `presente:` cases in tests/unit/supabase-server-url-opcional.test.ts follow the new contract (public base, internal transport). docs/deploy-selfhost/README.md and .env.example no longer claim REST, Realtime and Storage stop needing to be public: the browser still uses Realtime and Storage on the public URL, so it keeps serving Auth, Realtime and Storage. The gain is the server path.
15 KiB
DeskcommCRM self-hosted — instalação em VPS (com agente de IA)
Sistema operacional de vendas open source com agente SDR de IA integrado (WhatsApp via WAHA) — pra qualquer negócio que vende conversando. Este guia sobe TUDO numa VPS com
docker compose: app web, worker do agente, WAHA e proxy com HTTPS automático. Tempo estimado: ~30 min.
O que você precisa antes
| Item | Onde conseguir |
|---|---|
| VPS Linux (2 vCPU / 4 GB+) com Docker | qualquer provedor |
| Um domínio apontando para a VPS (registro A) | seu DNS |
| Projeto Supabase (o plano free serve; acompanhe a cota em Settings → Usage — ver runbook de custo) | supabase.com — é o Postgres+Auth+Storage do CRM |
| Chave Anthropic (ou cadastre BYOK depois na tela) | console.anthropic.com |
| Um número de WhatsApp para o agente | qualquer chip/celular |
Por que Supabase e não um Postgres no compose? O CRM usa Auth, Storage e RLS do Supabase nativamente. O caminho suportado é um projeto Supabase (cloud, free tier) — simples e com backup gerenciado. Supabase self-hosted também funciona, mas não é coberto por este guia.
1. Clonar e configurar
git clone https://github.com/melgarafael/DeskcommCRM.git && cd DeskcommCRM
cp .env.hostgator.example .env # o template de produção (o .env.example é o de dev)
Edite o .env e preencha (mínimo):
- Supabase (Settings → API do seu projeto):
NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEYOpcional:
SUPABASE_SERVER_URL. Só quando o seu Supabase roda na mesma rede do app (Kong, self-host) — preencha com o endereço interno (ex.:http://kong:8000) e as requisições do servidor passam a usar esse caminho curto, que não precisa sair para a internet. O endereço interno não substitui a pública: o navegador continua falando com ela (Auth, Realtime e Storage), e é dela que saem os links que o app entrega — mídia, avatar, PDF da LGPD e o redirect do login com Google. Vazia, tudo funciona como antes. - Banco direto (Settings → Database → connection string):
SUPABASE_DB_URLÉ a conexão do app. Quem mexe no schema —
create extension, obaseline.sql, a promoção do dono, opg_dumpdo backup — pode ser outra:SUPABASE_DB_ADMIN_URL. Ela é opcional e, vazia, tudo roda pela de cima (é o caso da nuvem: a string do pooler já vem privilegiada). Preencha quando o Postgres for seu e a de cima for uma role menor — ver §2. - Domínio:
DOMAIN,NEXT_PUBLIC_APP_URL=https://SEU_DOMINIO,WAHA_WEBHOOK_BASE_URL=https://SEU_DOMINIORodando SEM TLS (ex.:
http://IP:PORTA, sem o Caddy)? Basta oNEXT_PUBLIC_APP_URLcomeçar comhttp://— o cookie de sessão deixa de serSecureautomaticamente e o login funciona. Comhttps://,Securesempre ligado.Em
http://o app funciona 100%: as três Web APIs que somem fora de secure context estão tratadas (cookieSecure,crypto.randomUUIDenavigator.clipboard— os botões de copiar usam fallback). Ainda assim, HTTPS via Caddy é o recomendado de produção: fecha a família inteira de restrições de contexto não-seguro de uma vez. - Segredos (gere com
openssl rand -base64 32cada):INTERNAL_SECRET,INTERNAL_CRON_SECRET,CPF_ENCRYPTION_KEY,AI_CRED_AES_KEY,WAHA_BYO_ENCRYPTION_KEY,IMPERSONATE_COOKIE_SECRET,LGPD_SIGNING_KEY,SRH_TOKEN - WAHA:
WAHA_API_KEY(invente uma),WAHA_API_KEY_SHA512(echo -n "$WAHA_API_KEY" | shasum -a 512 | awk '{print $1}'),WAHA_HMAC_SECRET - IA:
ANTHROPIC_API_KEY(ou deixe vazio e cadastre a chave depois em/app/ai/credentials— fica cifrada no banco)
2. Aplicar o schema no Supabase
# uma vez, do seu computador ou da VPS (precisa do psql):
# projeto Supabase NOVO: habilite antes as extensões que o schema usa
DDL="${SUPABASE_DB_ADMIN_URL:-$SUPABASE_DB_URL}" # a do dono do banco, se houver
psql "$DDL" -v ON_ERROR_STOP=1 -c \
'create extension if not exists vector with schema public;
create extension if not exists citext with schema public;
create extension if not exists pg_trgm with schema public;'
psql "$DDL" -v ON_ERROR_STOP=1 -f supabase/baseline.sql
O baseline.sql é idempotente — cria o CRM inteiro + as tabelas do agente
(migrations 0001→atual). Para atualizar uma instalação existente, rode o mesmo
comando de novo, com a mesma flag: re-aplicar não erra.
Isso passou a ser verdade em 2026-08-13 (issue #184). Antes o arquivo era
idempotente só em parte — as tabelas tinham IF NOT EXISTS, índices, constraints e
policies não —, e re-aplicar com ON_ERROR_STOP=1 parava em
multiple primary keys for table "ai_agent_runs". Sem a flag saía "verde" com 301
erros dentro, e quatro deles faziam uma mudança de RLS não chegar ao clone.
O gate que prova isso é o job invariants, que agora re-aplica com a flag.
Usando Postgres próprio em vez de Supabase? Aplique ANTES o
scripts/selfhost-prelude.sql(roles/schemas/extensões que o dump supõe). Limite: auth/storage viram stubs — o login do app exige Supabase real; o worker/agente funcionam integralmente.
Crie também a role dedicada do worker (mais seguro que usar o superusuário):
create role agent_worker login password 'TROQUE-ESTA-SENHA' bypassrls;
grant usage on schema public to agent_worker;
grant select, insert, update, delete on all tables in schema public to agent_worker;
grant usage, select on all sequences in schema public to agent_worker;
grant execute on all functions in schema public to agent_worker;
Aponte SUPABASE_DB_URL do .env para ela — e deixe a conexão do dono em
SUPABASE_DB_ADMIN_URL. Antes isto era uma recomendação sem encaixe: o
install.sh e o update.sh usavam a MESMA string para o app e para o schema, e
quem seguia este parágrafo via o baseline.sql falhar por falta de permissão —
com a saída de editar o .env na mão entre uma etapa e outra (issue #192).
Para conferir quem é quem na sua instalação, sem acreditar neste texto:
# cada linha diz "chamada ao Postgres → com qual string"
grep -nE '(psql|pg_dump) "' hostgator-setup-kit/*.sh
url_do_schema (em hostgator-setup-kit/_common.sh) é a resolução: usa
SUPABASE_DB_ADMIN_URL e, ausente ou vazia, cai em SUPABASE_DB_URL.
Duas consequências que valem saber antes de escolher onde declarar:
- O
docker-compose.prod.ymlentrega o.envinteiro aoapp, aoworkere, com telefonia, aovoice-agent(env_file: .env), e todo serviço que recebe o.enva neutraliza noenvironment:— declará-la ali não a expõe. Para não deixá-la no arquivo, passe-a só no comando:SUPABASE_DB_ADMIN_URL='...' bash hostgator-setup-kit/install.sh. - Em compensação, o
update.shroda sozinho (cron doagent.sh) e é ele que entrega migration nova ao clone. Sem a chave no.env, cada atualização precisa da sua mão. Escolha consciente, não descuido.
O install.sh nunca grava SUPABASE_DB_ADMIN_URL no .env — se ela estiver
lá, foi você que pôs.
3. Subir
docker compose -f docker-compose.prod.yml up -d
A imagem do app vem pronta do GHCR (
APP_IMAGEno .env). Para buildar localmente (fork/sem registry): adicione-f docker-compose.build.ymle rode... buildantes doup(precisa de ≥4 GB RAM; ~15-25 min).
Sobe: caddy (HTTPS automático via Let's Encrypt) → app (CRM) → worker
(agente 24/7) → waha (WhatsApp) → redis/srh → scheduler (crons).
Confira: docker compose -f docker-compose.prod.yml ps — tudo healthy.
O worker loga agent-engine pronto (docker compose logs worker).
3.5 E-mails de auth (criar conta e recuperar senha)
O app tem signup self-service (/signup) e recuperação de senha
(/login/forgot). Os dois dependem do e-mail transacional do Supabase Auth
chegando com link para https://SEU_DOMINIO/auth/confirm.
O caminho automático (recomendado): com um Personal Access Token exportado,
export SUPABASE_ACCESS_TOKEN=sbp_... # supabase.com/dashboard/account/tokens
bash hostgator-setup-kit/marca-emails.sh
Ele sobe assunto e corpo dos dois e-mails com a marca da sua instalação
(APP_NAME do .env), configura Site URL e Redirect URLs, e relê o que
gravou para provar que a API aceitou. O install.sh já o chama sozinho
quando o token está no ambiente. Sem o token, ele imprime o passo manual e sai
sem quebrar nada.
O caminho manual: no Dashboard →
-
Authentication → Sign In / Up: habilite Allow new users to sign up e mantenha Confirm email ligado. Exceção: com a instalação em "Cadastro apenas por convite" (
/admin/cadastro), desligue Allow new users to sign up. Ligado, qualquer um cria conta direto no Supabase com a chave pública que vai ao navegador, passando por fora do CRM (#1653). O convite continua funcionando: com o cadastro público fechado, o app cria a conta do convidado pela admin API, no servidor. Voltou para "aberto" ou "com aprovação"? Ligue de novo, senão o cadastro pela tela falha. -
Authentication → URL Configuration:
Site URL = https://SEU_DOMINIOe adicionehttps://SEU_DOMINIO/auth/confirmem Redirect URLs. -
Authentication → Email Templates: troque o link dos templates Confirm signup e Reset password para o fluxo server-side:
<!-- Confirm signup --> <a href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}">Confirmar e-mail</a> <!-- Reset password --> <a href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}">Redefinir senha</a>⚠️
&, nunca?. O app já manda.RedirectTocom o?type=embutido (app/actions/auth/signUp.tserequestPasswordReset.ts), então um?aqui produz...?type=signup?token_hash=...: o navegador para de reconhecertoken_hashcomo parâmetro (ele vira parte do valor detype) e/auth/confirmmanda o usuário para/login?error=link_invalido— com o link correto. Esta seção ensinava a forma com?até 2026-08-14, e o projeto Supabase de produção estava com ela gravada: quem seguiu a receita reproduziu o defeito. -
SMTP próprio (Authentication → SMTP): o sender embutido do Supabase tem limite baixo (~2 e-mails/h) — configure Resend/SES/etc. para produção. Isto é sobre VOLUME de envio: editar o corpo do e-mail não exige SMTP próprio (medido em 2026-08-14:
PATCH /v1/projects/{ref}/config/authcommailer_templates_*responde 200 e persiste num projeto comsmtp_host: null).
GoTrue self-hosted: equivalente por env:
GOTRUE_DISABLE_SIGNUP=false (true em "só convite", pela mesma razão do
passo 1; no Supabase self-hosted oficial a chave do .env é DISABLE_SIGNUP.
No kit de servidor único, o update.sh grava essa chave sozinho a partir do
modo da instalação. A troca feita em /admin/cadastro só chega ao Supabase na
próxima atualização, nos dois sentidos),
GOTRUE_MAILER_AUTOCONFIRM=false,
GOTRUE_SITE_URL=https://SEU_DOMINIO,
GOTRUE_URI_ALLOW_LIST=https://SEU_DOMINIO/auth/confirm,
GOTRUE_SMTP_{HOST,PORT,USER,PASS} e
GOTRUE_MAILER_TEMPLATES_{CONFIRMATION,RECOVERY} apontando para as rotas do
próprio app:
GOTRUE_MAILER_TEMPLATES_CONFIRMATION=https://SEU_DOMINIO/email-templates/confirmation
GOTRUE_MAILER_TEMPLATES_RECOVERY=https://SEU_DOMINIO/email-templates/recovery
GOTRUE_MAILER_SUBJECTS_CONFIRMATION="Confirme seu e-mail · SUA MARCA"
GOTRUE_MAILER_SUBJECTS_RECOVERY="Redefinir sua senha · SUA MARCA"
O app serve o modelo já com a marca resolvida do banco — então trocar nome,
cor ou logo em Configurações › Marca chega ao e-mail sozinho, em até 10
minutos (GOTRUE_MAILER_TEMPLATE_MAX_AGE), sem reiniciar nada e sem rodar
script.
⚠️ Tem de ser URL
http(s). Caminho de arquivo NÃO funciona — e falha calado. O GoTrue cola o que não começa comhttpno fim doSITE_URLe faz um GET (supabase/authv2.196.0,internal/mailer/templatemailer/template.go:456). Apontar para/opt/.../confirmation.htmlfaz ele buscarhttps://SEU_DOMINIO/opt/.../confirmation.html, receber o HTML da tela de login e mandar isso para a caixa de entrada do cliente. Medido em 2026-09-09 numa instalação real: o Gmail marcou como phishing.Esta seção mandava exatamente isso até 2026-09-10. Se você seguiu a versão antiga, troque as duas variáveis pelas URLs acima.
bash hostgator-setup-kit/marca-emails.sh --render-em <dir> continua existindo
para inspecionar o HTML antes, ou para quem prefere servir os moldes por
conta própria — num caminho HTTP seu, nunca como caminho de arquivo.
4. Conectar o WhatsApp
- Acesse
https://SEU_DOMINIO/appe crie sua conta/organização. - Vá em Conexões → adicionar número → escaneie o QR com o WhatsApp do número do agente (Aparelhos conectados → Conectar aparelho).
- O status vira WORKING. (Se travar em SCAN_QR_CODE, gere novo QR — o watchdog do worker mantém o status sincronizado sozinho.)
5. Criar o agente (tudo pela tela)
- Agentes IA → Novo agent: nome, persona (system prompt), modelo, credencial, o número conectado, ferramentas (leitura de leads/pipelines etc.) e palavras-chave de handoff.
- Publicar. A partir do próximo turno o agente responde com essa config. Editar cria versão nova; publicar troca por ponteiro; reverter é um clique.
- Mande um WhatsApp de outro número para o número conectado — a resposta aparece no Inbox com o badge IA.
6. Operação
- Backup diário (do seu crontab na VPS):
0 3 * * * /caminho/repo/scripts/backup-db.sh /var/backups/deskcomm(restaure compg_restore --clean --no-owner -d "${SUPABASE_DB_ADMIN_URL:-$SUPABASE_DB_URL}" arquivo.dump—--cleanderruba e recria objetos, o que é trabalho de dono do banco) - Flywheel (auto-melhoria): o worker julga conversas reais a cada 6h
(
FLYWHEEL_INTERVAL_MS) e grava PROPOSTAS de melhoria de prompt emflywheel_distiller_proposals. Nada é aplicado sozinho: revise e cole o bullet no prompt do agente na tela, publicando uma versão nova. - Atualizar:
bash hostgator-setup-kit/update.sh— ele puxa a tag publicada, re-aplica obaseline.sql(idempotente), sobe e faz backup antes. Não useup -d --build: isso reconstrói na sua máquina em vez de puxar a imagem testada, e numa VPS com proxy reverso próprio oup -dprecisa dos dois arquivos de compose — omitir-f docker-compose.traefik.ymlrecria o contêiner sem as labels de roteamento e o domínio inteiro passa a responder 404, com o contêinerhealthy.
Solução de problemas
| Sintoma | Causa provável | Conserto |
|---|---|---|
Resposta do agente fica queued |
espelho de sessão divergiu do WAHA | o watchdog reconcilia e reenvia sozinho em ≤2 min; veja `docker compose logs worker |
Publish falha com channel_session_offline |
sessão não está WORKING | reconecte o número em Conexões |
| Turno duplicado | dois consumidores de dispatch | garanta AGENT_DISPATCH_CONSUMER=engine (default) — o cron nativo vira no-op |
| Worker não sobe: "schema do harness ausente" | baseline não aplicado | rode o passo 2 |