Operação
Aqui você leva o Load Balance do código local até o ar: o caminho real do deploy, as migrations que valem para todo ambiente, a chave que cifra as chaves de provedor e os portões que provam que a mudança não quebrou nada. Você também encontra o que verificar quando o container não sobe ou quando uma chave aparece em texto puro.
Os arquivos .github, .gitlab-ci.yml, .circleci e Jenkinsfile não existem no disco. Push em main não deploya nada: o deploy sai pelo painel, e nenhum gate roda sozinho. Isso muda o que é sua responsabilidade em cada deploy — você roda os portões antes de publicar.
Pré-requisitos#
Antes de qualquer operação você precisa destes quatro itens:
>=24.15.0 <25). É o mesmo major nos dois apps, nos dois Dockerfiles e no engines.node.DATABASE_URL. É a única variável de ambiente sem default: z.string().min(1, 'DATABASE_URL is required'). Sem ela o boot cai.REDIS_URL. Ele é fail-open, mas é o que segura os locks dos jobs.OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_POST_LOGOUT_REDIRECT_URI, CONTA_PROVISIONING_SECRET e APP_PUBLIC_URL. A falha é checada em bloco e o erro nomeia exatamente quais faltam.Tudo isso passa por um schema Zod único, executado no import do módulo de env: o ZodError vira Environment validation failed: e derruba o processo. Em produção quatro travas são throw, não warn — AUTH_DISABLED=true, COOKIE_SECRET vazia, COOKIE_SECRET menor que COOKIE_SECRET_MIN_LENGTH = 32 e CORS_ORIGINS vazia. O motivo está escrito no código: “operadores esquecem env vars e o serviço sobe silenciosamente aberto”.
O backend/.env.example serve para você copiar e ajustar. O .env real é ignorado pelo git e nunca deve ser commitado: é onde vivem KEY_ENCRYPTION_KEY, COOKIE_SECRET e os client secrets.
Topologia#
São quatro processos e duas superfícies. O painel administra; o proxy e a API de painel vivem no mesmo processo Fastify, o que faz o /health cobrir os dois.
| Serviço | Papel | Porta |
|---|---|---|
| Painel (SPA React) | Superfície de administração: cadastrar chaves, estratégias, logs, limites | 3016 |
| Backend (Fastify 5) | Proxy + API de painel + /ws/requests + jobs de plataforma | 3018 |
| PostgreSQL | Estado: chaves, grupos, requests, payloads, audit log | 5432 |
| Redis | Cache de apikey:* e locks dos jobs; fail-open | 6379 |
O compose.override.yml da raiz define só três deles — postgres, redis e backend — e é explicitamente para dev local. Ele não declara command: de propósito, e leva AUTH_DISABLED: 'true' e TRUST_PROXY_HOPS: '0'. Não copie esse arquivo para um host de produção.
Deploy#
Não existe CI. O fluxo real tem quatro passos manuais, nesta ordem.
-
Passe GIT_SHA como build-arg
O
backend/DockerfiletemARG GIT_SHA=unknowne o expõe comoENV GIT_SHA. Nada reescreve esse valor: o painel preenche o build-arg à mão, por deploy.bash# no painel, ao construir a imagem do backend --build-arg GIT_SHA=<sha curto da main> -
Passe os três build-args do painel, no mesmo deploy
O
frontend/Dockerfileprecisa deVITE_API_URL,VITE_PROXY_PUBLIC_URLeGIT_SHA. O Dockerfile explica por que cada um existe: semVITE_PROXY_PUBLIC_URLo painel mostra e copia a URL do SPA em vez da do proxy, que é o host do backend — foi um defeito reportado pelo dono. SemGIT_SHAobuildStampPlugingravadesconhecidono index.html. Os dois args têm de entrar juntos: um build que carimba desconhecido e mostra a URL do SPA é o estado em que a validação de tela acontece com menos informação.bash--build-arg VITE_API_URL=https://painel.exemplo \ --build-arg VITE_PROXY_PUBLIC_URL=https://proxy.exemplo \ --build-arg GIT_SHA=<sha curto da main> -
Publique pelo painel, lado alterado
Push em
mainnão constrói imagem nem reinicia container. O build usa a branch configurada e dispara o deploy daquela imagem; se você mexeu no painel, publique só o painel, e vice-versa. -
Prove que o código no ar é o que você quer
Com o CI desligado, o
assert-build-freshé a única resposta verificável para “qual código está no ar?”. Ele compara o sha que o/healthdevolve contra a sua ref local, por revisões git — não por hash de bundle, mtime nem artefato de build.bashcurl -s https://proxy.exemplo/api/health > /tmp/health.json cd backend && npm run assert:build-fresh -- --health-json /tmp/health.jsonbash[assert-build-fresh] sha no ar: b71f9c3 ref main -> b71f9c3 [assert-build-fresh] VERDE: o build no ar é o mesmo código da ref (b71f9c3).
O gate tem seis vereditos, e só um devolve 0 sem ressalva.
| Veredito | Exit | Quando acontece |
|---|---|---|
fresh | 0 | Contagem zero: o sha no ar é a ponta da ref |
unknown | 0 com aviso; 1 com --strict | O /health respondeu commit="unknown" — build sem build-arg |
stale | 1 | A ref andou depois do build no ar |
divergent | 1 | O sha não é ancestral da ref: branch lateral, ou ref local atrás do ar |
not-a-commit | 1 | O sha não existe neste repositório |
bad-ref | 1 | A ref local não resolve — o gate está cego, não verde |
Um sha velho é “confiável e errado” — bem pior que unknown, porque parece a resposta de quem checou. Por isso unknown sai com aviso explícito, e --strict (o modo que você deveria usar em produção) o reprova.
No servidor de produção, o frontend/server.js serve o build/ como estático e faz proxy de /api, /auth/sso e /auth/callback. Sem API_ORIGIN ele serve só estáticos e /api devolve 404, esperando um reverse proxy externo.
Migrations#
As migrations rodam no CMD do Dockerfile do backend, não no compose e não num job separado:
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]
Esse é o único ponto que vale para todo ambiente — compose local, Easypanel, Fly, Railway, k8s, docker run puro. As migrations viviam só no command: do compose, que é lido apenas no dev; as plataformas que buildam pelo Dockerfile ignoravam aquele command: e subiam código novo contra banco antigo. Foi a causa do incidente B-A6-017: o backend passou a fazer SELECT de uma coluna que ainda não existia e derrubou /api/stats e todo o proxy com P2022.
O && é deliberado: se a migration falhar, o servidor não sobe. O shell encerra com código diferente de zero e o node dist/index.js nunca executa.
Não há retry nem re-bootstrap de schema em runtime, e não há verificação de versão do schema no boot. Se o banco estiver fora de sync, o container morre e não serve tráfego — o CMD é a única proteção, e ela é suficiente justamente por ser a única.
São 21 entradas em backend/prisma/migrations/: 20 pastas de migration e o migration_lock.toml, de 20260922144123_init a 20261012120000_group_hedge_config. Duas compartilham o prefixo 20260929000000, o que o Prisma aceita porque ordena alfabeticamente pelo nome completo.
Fora do container, o script npm equivalente roda o mesmo comando — ele serve para inspecionar o estado do schema sem subir aplicação:
cd backend && npm run prisma:migrate
Segredos#
A variável que decide se as chaves de provedor são cifradas ou gravadas em claro é KEY_ENCRYPTION_KEY: base64 de 32 bytes.
openssl rand -base64 32
Quando presente e não-vazia, ela precisa decodificar para exatamente 32 bytes — senão o boot falha em qualquer ambiente. O complemento de rotação é KEY_ENCRYPTION_KEY_PREVIOUS.
A cifragem é AES-256-GCM, com o formato armazenado enc:v1: em base64url sem padding e IV de 12 bytes aleatório por cifragem. O kid é k1 (atual) ou k0 (anterior, durante a rotação). São três alvos: ApiKey.key, ApiKey.refreshToken e os tokens dentro de ApiKey.metadata.
O AAD amarra o valor cifrado à linha e ao campo (loadbalance:apikey:), então trocar um valor cifrado de linha ou de campo faz a decifragem falhar — não passa em silêncio.
A advertência está escrita literalmente no schema: “ATENÇÃO: perder a chave = perder TODAS as chaves de provedor, sem recuperação.” Não existe caminho de recuperação no código. Guarde em cofre e use a MESMA chave em todas as instâncias antes de rodar o backfill — a guarda de boot falha citando o id da linha, nunca o valor.
Sem a chave, a escrita de segredo cai em texto puro (legado) com console.warn. A leitura de um valor cifrado falha fechada: o processo nunca manda enc:v1:... ao provedor. No boot, assertSecretsReadable roda logo após connectDatabase() e varre toda linha de api_keys com segredo cifrado, cobrindo a API e os jobs — que são o mesmo processo.
O backfill e a ordem segura
Cifrar um banco que já tem chaves em claro é feito por backend/scripts/backfill-secrets.ts, idempotente — rodar duas vezes dá o mesmo resultado.
| Script | Comando |
|---|---|
secrets:encrypt-backfill | tsx scripts/backfill-secrets.ts --direction=encrypt |
secrets:decrypt-backfill | tsx scripts/backfill-secrets.ts --direction=decrypt |
A chave é obrigatória inclusive no --dry-run: sem ela o plano de ida não cifra nada e contaria 0. O script lê e grava com o PrismaClient puro, sem extensions, sobre os valores crus; trabalha em lotes por cursor de id com uma transação por lote — ou o lote inteiro entra, ou nada entra. As flags são --dry-run e --batch=N, com 200 como default.
A ordem de produção não é negociável:
KEY_ENCRYPTION_KEY idêntica — em todas as instâncias.--dry-run: leia o plano antes de escrever.secrets:decrypt-backfill precisa rodar antes de qualquer rollback de código para a versão sem SEC-KEYS. O código antigo mandaria enc:v1:... cru ao provedor, e isso não é detectável pelo /health.
Trabalhando no código#
Os dois apps têm portões diferentes, porque são dois apps independentes — não é monorepo com workspaces, cada um tem seu package.json e seu package-lock.json.
Painel: um portão só
O gates é a porta única do frontend. Com o CI removido do repositório, ele é o único portão que existe:
cd frontend && npm run gates
# check-lock → typecheck → lint → cycles → format:check → test → test:a11y → build
Backend: sem agregador
O backend não tem comando agregador equivalente. Rode os que importam:
| Comando | O que roda | Por que importa |
|---|---|---|
npm run typecheck | tsc --noEmit | Erro de tipo no build |
npm run test:it | node scripts/run-integration-tests.mjs | Suíte com gate de skips |
npm run typecheck:test | node scripts/typecheck-tests.mjs | O vitest transpila com esbuild e não checa tipo |
npm run assert:no-skips | node scripts/assert-no-skips.mjs | Reprova se algum teste foi pulado |
npm run assert:build-fresh | node scripts/assert-build-fresh.mjs | Prova que o que está no ar é o da ref |
A suíte de segurança não é um diretório nem uma tag: são cerca de 27 arquivos em test/ que abrem com describe.skipIf(!url), condicionado a TENANCY_IT_DATABASE_URL e TENANCY_IT_REDIS_URL. O vitest.config.ts inclui esses arquivos, então npm test roda eles — executa com o skip ativo, conta tudo como pulado e sai 0. npm run test:it executa a mesma suíte e reprova nomeando cada arquivo.
O test:it resolve as duas variáveis do ambiente e, se faltarem, de backend/.env — só as chaves TENANCY_IT_*; o resto do .env nunca entra no processo do teste. Ele não roda containers: quem cria o schema e o banco Redis é o test/globalSetup.ts, um por execução (o PID), justamente para que o TRUNCATE … CASCADE global de um arquivo não atravesse o outro.
O relatório é apagado antes de cada rodada. Sem isso, um vitest que morre antes de escrever o JSON deixa o relatório da rodada anterior e o gate leria como se fosse o de agora.
Se nenhuma das duas variáveis existir, o test:it imprime um aviso e roda a suíte assim mesmo — de propósito, “para que o gate possa dizer quantos testes foram deixados de fora”. Ele não faz exit. O banco de teste precisa ser descartável.
O vitest marca teste pulado com um glifo, e casar esse glifo com grep -E depende de locale: sob LC_CTYPE=POSIX o bracket vira conjunto de bytes e a linha nunca casa. Medido em 7aaa1859: o gate por regex devolveu “0 pulados” com 135 testes pulados. O script conta por campo numérico e por conjunto de status, não por glifo.
Os códigos de saída são 1 se reprovou, 2 para flag desconhecida e 0 no sucesso. Ele reprova em cinco casos independentes: relatório ausente, relatório sem os campos de contagem, contagem total zero (o “verde mais silencioso que existe”), teste pulado — nomeando cada arquivo — e teste falhando.
Importar muitas chaves#
Para subir um lote grande num grupo, o caminho é backend/scripts/import-keys-lote.ts. Ele usa o mesmo caminho da UI (loadBalancer.addKey por chave), então a tenancy e a cifragem do key no banco acontecem sozinhas, e os caches do pool em Redis são invalidados a cada inserção — depois do --apply as instâncias do LB passam a ver as chaves sem restart.
O arquivo de chaves tem uma chave por linha. Linha vazia ou começando com # é ignorada; o valor pode vir cru ou como nome=valor / nome: valor, e nesse caso só o valor entra.
cd backend
npx tsx scripts/import-keys-lote.ts \
--provider=openrouter \
--group=lote-marco \
--file=/tmp/chaves.txt
org: workspace_nommand (…)
grupo 'lote-marco' não existe — seria CRIADO (strategy round_robin) no --apply
provider=openrouter baseUrl=https://openrouter.ai/api authType=api_key chaves=240 nomes=lote-marco-001..lote-marco-240 active=true cifragem=ON
--- dry-run: nada foi escrito. Releia os prefixos abaixo e rode com --apply ---
lote-marco-001 sk-or-…(68)
… +230
Sem --apply o script mostra o plano e não escreve nada — nem no grupo, nem nas chaves. Releia os prefixos mascarados, confirme que os nomes são os que você quer e só então aplique.
A checagem vem antes do dry-run e diz: ERRO: KEY_ENCRYPTION_KEY ausente — sem ela as chaves iriam em TEXTO PURO. Aborto. É o mesmo motivo que trava o backfill-secrets.ts: um caminho de importação que aceita gravar em claro é um caminho que alguém usa sem querer.
Nenhum valor de chave aparece no log. O mascaramento mostra os 4 ou 6 primeiros caracteres mais o comprimento, e o relatório final mostra só contagens e prefixos. O próprio cabeçalho do script é explícito: o arquivo de chaves e as envs ficam só na máquina que executa — nunca cole chave em argumento de comando nem em log.
O script também recusa, antes de escrever: chave repetida no arquivo, nome que já existe naquela org (nomeando até 10), provider desconhecido e --name-width fora da faixa 1 a 10. Os nomes saem numerados com o prefixo que você definir em --name-prefix, começando em --start; a org dona é --org.
Depois do --apply, ele relê a coluna crua com um cliente puro e confirma que nada entrou em texto puro — se algo entrar, ele falha nomeando as linhas:
OK: 240/240 chaves em 'lote-marco' em 4.7s; 240 linhas no banco, 0 em texto puro (esperado: 0)
Para ver os ids e a baseUrl padrão dos providers antes de importar, rode --list-providers. Ele funciona sem banco: só precisa de um DATABASE_URL qualquer no ambiente para o schema Zod passar.
Quando algo está errado#
Os quatro sintomas que mais aparecem em produção, cada um com o que procurar e o que fazer.
Chave de provedor aparece em texto puro
Sintoma: a verificação final do import reporta texto puro, ou o seu próprio SQL — um SELECT LEFT(key, 7) — mostra algo que não começa com enc:v1.
Se KEY_ENCRYPTION_KEY está vazia na instância que escreveu, a gravação foi passthrough legado com console.warn no log. Trate as chaves como expostas: ponha a chave em todas as instâncias, reinicie, rode secrets:encrypt-backfill e verifique em SQL. Se a chave está presente mas errada em alguma instância, a guarda de boot derruba aquele processo citando o id da linha.
COOKIE_SECRET curta ou vazia
COOKIE_SECRET vazia em produção é throw, e abaixo de COOKIE_SECRET_MIN_LENGTH = 32 também. O /api/health expõe o modo em cookieSecret: 'env' | 'derived' — nunca o valor — e o padrão de transição é derivar o segredo do DATABASE_URL. Se você trocar o valor, todas as sessões abertas caem uma vez.
CORS_ORIGINS vazia em produção
A trava é a mesma política das outras três: throw, não warn. Preencha a lista de origens e redeploye. Se o painel carregou mas as chamadas a /api falham no browser, comece por aqui.
Banco fora de sync e o container não sobe
O && do CMD faz prisma migrate deploy rodar antes do servidor, e uma falha encerra o processo com código diferente de zero. O sintoma é um container reiniciando sem nenhuma linha do servidor no log. Rode npm run prisma:migrate de uma máquina com acesso ao banco e leia o erro do Prisma antes de investigar qualquer outra coisa.
Próximos passos#
Você já sabe publicar, cifrar e prová-lo. Para fechar a operação, guarde os nomes das variáveis e o comportamento de cada uma — é onde a maior parte dos incidentes de configuração nasce.
Depois de dominar o básico, explore além: