Deploy e ambiente
Executando localmente (desenvolvimento)
Pré-requisitos
- Java 21+
- Maven 3.9+
- Node.js 20+ e npm
- PostgreSQL 16 com a extensão
pgvector, local ou via Docker.docker-compose.ymlusa a imagempgvector/pgvector:pg16; um PostgreSQL 16 sem a extensão falha na criação dovector_storeno start
1. Subir o banco de dados
docker compose up -d dbIsso inicia apenas o container do PostgreSQL na porta 5432.
2. Executar o backend
cd backend
export DB_HOST=localhost
export DB_PORT=5432
export DB_NAME=flow-iagentic
export DB_USERNAME=flow-iagentic
export DB_PASSWORD=flow-iagentic
export JWT_SECRET=dev-secret-key-min-256-bits-for-hmac-sha256-algorithm
mvn install -DskipTests
mvn spring-boot:run -pl core -Dspring-boot.run.profiles=dev -Dcheckstyle.skip=trueO backend estará disponível em http://localhost:8080.
Duas armadilhas deste passo — mvn package no lugar de mvn install, e o container que não recarrega sozinho — estão em Diagnóstico do ambiente local, ao fim desta seção.
O terceiro artefato: o agente de runner
O mesmo mvn install também gera backend/runner-agent/target/flowia-runner-agent.jar. Ele não é a aplicação nem o RobotRunner.jar: é o agente de longa duração que se instala na máquina do cliente, busca trabalho na plataforma e sobe o sandbox por lá.
O agente não pode depender do core
runner-agent roda numa máquina que a plataforma não administra. Uma dependência para o core levaria junto o roteamento de datasource por tenant, o engine Flowable e o driver do banco para dentro da rede do cliente — e compilaria sem quebrar nada. AgentCarriesNoCoreTest lê o classpath resolvido e falha se isso acontecer; é ele que faz a violação aparecer no build em vez de aparecer num servidor de cliente.
O ciclo fecha de ponta a ponta: a plataforma atribui, o agente busca em /api/internal/runners/work, baixa os dois JARs conferindo o SHA-256, sobe o filho e devolve o desfecho. Os detalhes estão em specs/automation/robot-runners.md.
Antes de subir o agente é preciso cadastrar o runner na tela, colando a chave pública que o --init imprime. Os comandos e o passo a passo da tela estão em Robôs → Cadastrando um runner.
3. Executar o frontend
cd frontend
npm install
npm run devO frontend estará disponível em http://localhost:3000 (vite.config.ts → server.port). Servir em outra porta exige acrescentá-la a app.cors.allowed-origins, senão toda chamada responde Invalid CORS request.
Diagnóstico do ambiente local
install não é opcional, e package não serve
O reator tem três módulos: core é a aplicação, robot-runner é o sandbox onde os robôs rodam e runner-agent é o agente instalado na máquina do cliente. core depende de robot-runner, e resolve essa dependência pelo repositório local ~/.m2 — não pela pasta target/ do irmão. runner-agent não depende de nenhum dos dois, de propósito.
mvn package compila os dois e não publica nada em ~/.m2, então o cache continua com a versão anterior do robot-runner. mvn install é o que atualiza. A falha não aparece no build: aparece no próximo boot, como erro de compilação dentro do container, apontando para código que você jurava ter corrigido.
Se você usa o ambiente em Docker, o ~/.m2 do host é montado dentro do container (docker-compose.dev.yml), então o cache é o mesmo — e o problema também.
O reinício do container é pedido por você, não pelo compilador
docker-compose.dev.yml monta ./backend/core/target dentro do container, e esse mount é o mecanismo do hot reload: você compila no host e o container enxerga os .class novos na hora.
Ele não recarrega sozinho. Quando o build terminar, peça o reinício:
touch backend/core/target/classes/.reloadtriggerO DevTools então aplica de uma vez tudo que mudou desde o último reinício (~1-2s, sem reiniciar a JVM). Vale igual para um build feito dentro do container (docker compose exec app mvn compile -q -pl core).
O que segura o reinício automático é spring.devtools.restart.trigger-file: .reloadtrigger, em application-dev.yml — só o profile dev, nunca prod nem staging. SPRING_DEVTOOLS_RESTART_ENABLED=false não serviria: spring-boot:run instala o RestartInitializer e o DevTools reinicia mesmo assim, dizendo isso no log. O trigger-file, ao contrário, é lido depois, pelo autoconfigure — o watcher continua de pé e só deixa de tratar um .class novo como motivo para reiniciar.
Sem o trigger-file, qualquer build reinicia o container — inclusive um mvn test de uma classe só, e inclusive no meio da compilação, com as classes ainda pela metade. Os sintomas não se parecem com a causa: NoClassDefFoundError, uma @Configuration que nunca registra, um preflight recusando o que deveria aceitar.
Se você tocar o arquivo e nada acontecer, confira que ele está em target/classes (um mvn clean apaga; o touch recria) e que o log do container mostra o Restarting due to ....
Variáveis de ambiente (Backend)
| Variável | Descrição | Padrão (dev) |
|---|---|---|
DB_HOST | Host do PostgreSQL | localhost |
DB_PORT | Porta do PostgreSQL | 5432 |
DB_NAME | Nome do banco | flow-iagentic |
DB_USERNAME | Usuário do banco | flow-iagentic |
DB_PASSWORD | Senha do banco | flow-iagentic |
JWT_SECRET | Chave secreta para assinar JWTs | — |
APP_ENCRYPTION_KEY | Cifra credencial de terceiro em repouso: chave do provedor de IA, segredo de assinatura dos webhooks, variável de tenant secreta, o client secret do SSO e as configurações de e-mail. Perdê-la torna tudo isso ilegível, e ela não pode ser rotacionada depois que há segredo gravado | — |
SECRETS_INJECT_INTO_PROCESS_VARIABLES | Com false — recomendado — variável secreta nunca chega à instância, então as tabelas de runtime e de histórico do Flowable não guardam credencial. A expressão passa a usar secrets.get(execution, 'chave') | true |
AI_HTTP_TOOL_DEFAULT_POLICY | Com deny, agente sem lista de destinos não chama fora — recomendado | allow |
AI_STEP_CORE_SIZE | A capacidade de IA da instalação: etapas de IA simultâneas em regime, na pool dedicada em que a etapa espera o provedor. É este número que se cita | 8 |
AI_STEP_QUEUE_CAPACITY | Etapas de IA que esperam vaga antes de a pool recusar. Recusada, a etapa é encerrada como falha e fica disponível para reprocesso | 100 |
AI_STEP_PROVIDER_TIMEOUT_SECONDS | Quanto a etapa espera o provedor antes de desistir. O lease da reserva é o dobro disso | 120 |
FLOWABLE_EXECUTOR_CORE_SIZE | Jobs do Flowable simultâneos em regime — timers, continuações assíncronas e a conclusão da etapa de IA. Desde 2026-09-09 não é o teto da IA | 2 |
FLOWABLE_EXECUTOR_MAX_SIZE | Teto do pool de jobs. A thread extra só nasce com a fila de 100 cheia, e nesse ponto o job já está sendo rejeitado — não é capacidade | 3 |
JWT_EXPIRATION_MS | Expiração do access token (ms) | 3600000 (1h) |
SPRING_PROFILES_ACTIVE | Perfil Spring ativo | dev |
APP_NODE_ROLE | all, api ou worker. Decide se o nó adquire job do motor, roda tarefa agendada de cluster e aplica o Flyway compartilhado. Ver "Papéis de nó" | all |
APP_NODE_AFFINITY_ENABLED | Com true, cada tenant tem um nó dono e o nó só adquire job dos tenants que possui. Ver "Afinidade de tenant" | false |
APP_NODE_AFFINITY_HTTP | Declara que o roteador na frente distribui por X-Tenant-ID. Não muda o roteamento: só a conta de conexões, que a aplicação não tem como observar sozinha | false |
APP_DATABASE_MAX_CONNECTIONS | max_connections do servidor de banco. 0 significa "não informado" e desliga a comparação | 0 |
APP_DATABASE_EXPECTED_TENANTS | Quantos tenants ativos a instalação espera. Entra na conta do teto de conexões | 0 |
APP_DATABASE_EXPECTED_PODS | Quantos pods de backend a instalação espera | 1 |
APP_DATABASE_BUDGET_ENFORCE | Com true, o nó recusa subir quando a conta passa de APP_DATABASE_MAX_CONNECTIONS, em vez de só avisar | false |
APP_DATABASE_ROLE_ENFORCE | Com true, o nó recusa subir quando a role de banco é rolsuper ou rolbypassrls, em vez de só avisar. Ver "A role de banco" | false |
AI_FAIL_ON_EMPTY_RESPONSE | Falha a tarefa quando o agente devolve resposta vazia, em vez de gravar um valor em branco | true |
AI_LOG_RAW_RESPONSE_ON_EMPTY | Loga a resposta bruta do provedor quando ela chega sem nenhum candidato, para diagnosticar o motivo do bloqueio | true |
AI_TASK_FAILURE_RETENTION_DAYS | Dias que um registro de falha de agente fica guardado antes da limpeza diária | 90 |
FORM_PASSWORD_HISTORY_RETENTION_DAYS | Dias que a senha digitada num campo de formulário fica no histórico, já cifrada, antes da varredura noturna apagá-la | 7 |
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE | O único pool do nó, e por isso o orçamento inteiro dele: (pool + 1) × pods. Tem piso — ver "O piso do pool, e por que ele não desce sozinho" | 10 |
TENANT_POOL_MAX_SIZE | Teto de conexões que um tenant pode segurar dentro do pool compartilhado. Não é um pool e não abre conexão nenhuma: é o anteparo que impede uma empresa de consumir a conexão das outras. O padrão vem do papel do nó: 7 em all/worker, 3 em api. Tem o mesmo piso | 7 |
APP_STORAGE_TYPE | S3 ou LOCAL. Fora dos perfis dev e test a aplicação recusa subir com LOCAL | LOCAL (só serve em dev) |
APP_STORAGE_S3_ENDPOINT | Endpoint compatível com S3. Vazio significa a própria AWS S3 | — |
APP_STORAGE_S3_REGION | Região do bucket | us-east-1 |
APP_STORAGE_S3_BUCKET | Bucket onde ficam anexos, fotos de perfil e JARs de robô. Obrigatório fora de dev/test | flow-iagentic |
APP_STORAGE_S3_ACCESS_KEY | Chave de acesso. Obrigatória fora de dev/test | — |
APP_STORAGE_S3_SECRET_KEY | Chave secreta. Obrigatória fora de dev/test | — |
Deploy Enterprise (On-Premise / Self-Hosted)
O Flowi Agentic foi desenhado para rodar dentro da infraestrutura cloud privada do próprio cliente (AWS VPC, Azure, VM Local). Devido ao modelo BYOK (Bring Your Own Key), a instância roda isolada e não envia dados de telemetria ou billing para a Flowi Agentic Inc. Apenas a chave serial on-premise é configurada para destravar a licença.
# Build e start do cluster (Node padrão)
docker compose up --build -dPara cluster Kubernetes do cliente, use o Helm chart da seção seguinte em vez do Docker Compose.
Uma segunda réplica funciona, e agora tem papel próprio
O trabalho agendado não duplica: CRONs de robô, e-mails e limpezas passam por um lease em banco antes de executar, então só um nó roda cada um.
Os três obstáculos que falhavam em silêncio saíram:
- Tempo real chega aos dois pods. O que um nó publica passa por
LISTEN/NOTIFYdo Postgres e é entregue às sessões STOMP de todos. Não há broker novo para instalar e não é preciso afinidade de sessão. - Java Delegate enviado a um pod aparece nos outros dentro de
app.delegates.reconcile-interval-ms(60 s por padrão), sem reiniciar ninguém. - Limites de taxa são da instalação. O limitador de login e o orçamento de cada chave de API contam numa tabela compartilhada, então o número na tela é o número que vale com um pod ou com cinco.
O Flyway também saiu do boot: cada schema de tenant migra na primeira requisição dele e uma varredura com lease cuida do resto, então o tempo de boot não cresce mais com a quantidade de tenants.
Os runners escalam por conta própria, e por outro botão. runner.replicaCount é um StatefulSet: cada réplica é um runner distinto, com par de chaves próprio no próprio volume. Como um runner compartilhado roda uma execução por vez, é esse número — não backend.replicaCount — que decide quantos robôs correm ao mesmo tempo.
O segundo pod agora pode ter papel. APP_NODE_ROLE=api serve HTTP sem adquirir job, sem rodar tarefa agendada de cluster e sem aplicar o Flyway compartilhado, com teto por tenant menor; APP_NODE_ROLE=worker faz o inverso e responde só saúde no HTTP. Sem a variável o nó é all, que é o comportamento de sempre. Ver "Papéis de nó".
O que decide a contagem de pods é o orçamento de conexões, e ele multiplica só por pod: (sharedPool + 1) x pods — o parêntese é o custo de um pod, e o +1 é a conexão que ele mantém aberta para escutar os eventos. Desde 2026-09-09 não há parcela por tenant nessa conta. A aplicação faz a conta e a imprime em toda subida; informe APP_DATABASE_MAX_CONNECTIONS e APP_DATABASE_EXPECTED_PODS e ela avisa quando o número não cabe (APP_DATABASE_EXPECTED_TENANTS continua saindo na linha só para mostrar que não a move). Ver "Orçamento de conexões".
O volume ReadWriteOnce continua sendo o obstáculo que o chart detecta e recusa. Com S3 obrigatório ele é só cache: persistence.enabled=false tira esse do caminho.
Serviços no Docker
| Serviço | Container | Porta |
|---|---|---|
| Banco de dados | flow-iagentic-db | 5432 |
| Backend | flow-iagentic-app | 8080 |
Frontend
O frontend não roda em Docker por padrão — execute localmente com npm run dev.
O docker-compose não sobe runner; o chart sobe
O docker-compose não sobe nem cadastra runner nenhum: aqui, robô só executa depois que alguém cadastra um runner na tela e sobe o agente na máquina dele — ver Cadastrar um runner é parte de subir a instalação.
No Helm é diferente desde 2026-08-29. O chart sobe runner.replicaCount runners (1 por padrão), cada um gerando o próprio par de chaves dentro do próprio volume e se inscrevendo sozinho como GLOBAL. A chave privada nasce no pod que assina com ela e não viaja. Runner de um tenant específico continua exigindo alguém colando a chave pública na tela.
Deploy em Kubernetes (Helm)
O chart fica em helm/ e instala backend + SPA + runners no cluster do cliente. Ele não baixa nenhuma dependência: não há subchart remoto, então a instalação funciona em cluster sem saída para a internet, bastando as imagens estarem acessíveis.
Requisitos
| Kubernetes | 1.23+ |
| Helm | 3.8+ |
| Ingress controller | qualquer um — ou nenhum, com ingress.enabled=false |
| PostgreSQL | 16 com pgvector, embutido ou externo |
| Object storage | obrigatório — qualquer endpoint compatível com S3, mais um bucket e uma chave que leia, escreva e apague nele |
Avaliação — banco embutido, bucket seu
cd helm
helm install flowia ./flowia -n flowia --create-namespace --wait --timeout 20m \
--set storage.s3.endpoint=https://s3.empresa.com.br \
--set storage.s3.bucket=flowia-anexos \
--set storage.s3.accessKey=... --set storage.s3.secretKey=...
kubectl -n flowia port-forward svc/flowia-frontend 8080:80Sobe com PostgreSQL embutido e segredos gerados. As quatro flags de storage não são opcionais: object storage é requisito, e uma instalação sem elas falha na renderização em vez de falhar quando alguém anexar um arquivo. O primeiro boot roda o Flyway no schema compartilhado e em cada schema de tenant — leva minutos, e a startupProbe espera até 600s antes de declarar o pod morto. Não encurte esse tempo achando que travou.
O port-forward não entrega tempo real
As atualizações ao vivo da caixa de tarefas usam WebSocket em /ws, e quem roteia /ws é o Ingress — não o nginx do frontend. Sem Ingress, a tela funciona mas não atualiza sozinha.
Produção
helm install flowia ./flowia -n flowia --create-namespace \
-f examples/production.yaml --wait --timeout 20mhelm/examples/production.yaml é o formato, não os valores: banco do cliente, ingress do cliente, segredos gerenciados pelo cliente.
O banco é uma chave liga/desliga
# embutido (padrão) — StatefulSet pgvector + PVC, para avaliação
--set postgresql.enabled=true
# externo — o banco que o cliente já opera
--set postgresql.enabled=false \
--set externalDatabase.host=pg.empresa.com.br \
--set externalDatabase.database=flowia \
--set externalDatabase.username=flowia \
--set externalDatabase.existingSecret=flowia-dbO banco embutido não tem backup, failover nem pooler — existe para a avaliação instalar em um comando. O banco externo precisa permitir CREATE EXTENSION vector (o Spring AI cria a extensão no boot) e permitir que a aplicação crie schemas, porque cada tenant é um schema.
Se faltar externalDatabase.host, o helm template falha na hora com a mensagem escrita — não vira CrashLoopBackOff meia hora depois.
Object storage é requisito, não opção
Toda instalação exige S3. storage.type já vem S3, e storage.s3.endpoint e storage.s3.bucket não têm padrão: se faltar qualquer um dos dois, o helm template falha na hora com a mensagem escrita — do mesmo jeito que falha sem externalDatabase.host. Um helm install sem valor nenhum não é válido.
--set storage.s3.endpoint=https://s3.empresa.com.br \
--set storage.s3.bucket=flowia-anexos \
--set storage.s3.accessKey=... --set storage.s3.secretKey=...
# ou: --set secrets.existingSecret=flowia-secrets, com S3_ACCESS_KEY e S3_SECRET_KEY dentrostorage.type=LOCAL existe apenas para desenvolvimento e teste. Ali o volume em /data é o armazenamento dos anexos: um disco ReadWriteOnce, sem backup, sem réplica, sem política de ciclo de vida e sem segundo leitor — perder o volume é perder todos os anexos da instalação. A aplicação recusa subir com LOCAL fora dos perfis Spring dev e test, então um chart que renderize LOCAL não vai produzir um pod de pé.
storage.type | O volume em /data | persistence.enabled=false |
|---|---|---|
S3 (padrão) | é só cache dos JARs de robô | permitido — vira emptyDir |
LOCAL (dev/teste) | é o armazenamento dos anexos | recusado pelo chart |
O JAR do robô é gravado no storage durável no upload e baixado sob demanda quando some do disco, por isso o cache pode ser efêmero. persistence.enabled vem true por padrão para que um pod novo não precise buscar todos os JARs no bucket antes da primeira execução; com S3, desligá-lo custa essa latência e nada mais.
O chart não sobe MinIO. Aponte storage.s3.endpoint para o object storage que o cliente já tem — inclusive um MinIO que ele mesmo opere, o que é licença e decisão dele. Deixar o endpoint vazio é recusado em vez de virar "AWS S3": uma instalação on-premise que subisse os anexos da empresa para a Amazon calada é pior do que uma que não sobe.
Retenção de arquivo é regra do bucket, não job nosso
Expurgar anexo antigo não é trabalho da plataforma: ela não tem job de limpeza de arquivo e não vai passar a ter. Expirar objeto velho é uma lifecycle rule do bucket (ou o ILM do appliance), configurada por quem é dono do bucket, ao lado da política de retenção e de guarda legal que a empresa já tem.
Apagar um anexo pelo produto apaga o objeto na hora — isso é exclusão sob demanda, não retenção. Se você escrever uma regra por idade, limite o escopo para que ela não expire objetos que o banco ainda referencia, senão o anexo volta para o usuário como erro de download.
Migrando uma instalação que já roda em LOCAL
O upgrade não move os arquivos. Eles estão no PVC; nem o Helm nem o backend copiam nada. Nada nessa mudança apaga um anexo, e nada nela copia um também. Os passos, nesta ordem:
- Crie o bucket no object storage que o cliente já opera, e uma chave que leia, escreva e apague dentro dele.
- Copie os arquivos do volume. Eles estão em
persistence.mountPath—/data/attachmentspor padrão — no formato<diretório>/<arquivo>, que é exatamente a chave S3 que oS3StorageProviderteria gravado. Ummc mirrorouaws s3 syncde/data/attachments/para a raiz do bucket reproduz o layout. Copie de um pod com o volume montado, ou de um snapshot; o backend pode continuar no ar. - Reescreva
attachments.storage_path. É o passo fácil de esquecer e impossível de pular: comLOCALa coluna guarda um caminho absoluto (/data/attachments/<diretório>/<arquivo>), comS3ela guarda a chave (<diretório>/<arquivo>).attachmentsé tabela por schema de tenant, então o update roda uma vez em cadatenant_<slug>, não uma vez só. - Só então faça o upgrade com os valores de storage preenchidos.
Para instalação pequena existe o atalho, e ele é um atalho mesmo: reenviar os anexos pela tela e deixar a plataforma gravar em S3. Funciona, gera linhas novas com chaves novas, e não escala além de algumas dezenas de arquivos.
Memória: robôs não rodam dentro do container do backend
Nenhum robô abre na JVM da aplicação: toda execução é atribuída a um runner cadastrado, que é outra máquina (ou outro pod) rodando o agente. backend.resources dimensiona a plataforma sozinha, e o teto de heap do sandbox é do agente, na máquina dele — o chart não tem chave para isso.
Quem dimensiona memória para robô dimensiona a máquina do runner. Uma instalação sem runner cadastrado não gasta memória com robô nenhum — e também não executa robô nenhum.
Cadastrar um runner é parte de subir a instalação
Sem pelo menos um runner cadastrado e respondendo, as execuções ficam PENDENTE para sempre, com o motivo escrito na própria linha. A plataforma não executa robô por conta própria.
No Helm o chart já resolve isso: ele sobe runner.replicaCount runners (1 por padrão) que geram a própria chave no próprio volume e se inscrevem sozinhos como GLOBAL. Só a inscrição feita de dentro do release faz isso, e ela só cria runner GLOBAL — quem segura o token ganha uma fatia do pool compartilhado, não o trabalho de um tenant.
Fora do Helm, e para qualquer runner de um tenant específico, o cadastro é manual, e continua sendo de propósito: decidir qual máquina recebe o código de um tenant é decisão de gente (specs/automation/robot-runners.md → How the chart installs runners).
O passo é uma vez por máquina, e está descrito com os comandos e a tela em Robôs → Cadastrando um runner. O que cabe a quem sobe a instalação é app.api.base-url: o agente confere na partida se alcança o RPC da plataforma e para com a mensagem do endereço se não alcançar. localhost ali é a própria plataforma, nunca a máquina do runner. O chart preenche esse endereço sozinho — a URL do Ingress quando há um, o Service interno quando não há — e app.apiBaseUrl nos values sobrescreve.
O runner não pode ser a máquina da aplicação
Instalar o agente no mesmo host do backend recoloca o código do tenant no disco que guarda a configuração, as credenciais e os anexos de todos os outros — que é exatamente a exposição que executar fora da aplicação evita. O chart não impede isso; a escolha é de quem instala.
O envelope de operação: o que uma instalação aguenta
Antes das contas, o resumo honesto: a escalabilidade desta plataforma é limitada pelo banco, e isso é consequência do isolamento por schema, não um defeito a consertar. Cada tenant tem o próprio schema, e é isso que garante que uma empresa não enxergue dado de outra. O que o banco cobra, porém, não cresce mais com o número de empresas: desde 2026-09-09 há um pool só por nó, e o custo é do pod.
Três limites decidem o tamanho de uma instalação. Só um deles é HTTP, e não é o que aperta.
| Limite | O que o fixa | Como se manifesta |
|---|---|---|
| Conexões de banco | (sharedPool + 1) × pods | FATAL: sorry, too many clients already numa requisição de usuário |
| Concorrência de passo de IA | AI_STEP_CORE_SIZE (8 por padrão), por instalação | fila de etapas de IA crescendo; a espera aumenta, e a etapa recusada por fila cheia vai para reprocesso |
| HTTP | CPU e memória do pod | raramente é o gargalo aqui |
Dimensionamento pelo banco. Com o padrão (sharedPool 10), cada pod custa 11 conexões — as 10 do pool mais a do barramento de cluster — e esse número não muda com o número de clientes. Quantos pods cabem, deixando de fora a folga que o Postgres reserva para superusuário e para a sua própria sessão de psql:
max_connections | Tier típico no Cloud SQL | pods que cabem, com qualquer número de tenants |
|---|---|---|
| 25 | db-f1-micro | 2 |
| 50 | db-g1-small — o do deploy gerenciado | 4 |
| 100 | 1 vCPU / 3,75 GB | 9 |
| 200 | 2 vCPU / 7,5 GB | 18 |
| 400 | 4 vCPU / 15 GB | 36 |
A conta, para conferir qualquer linha: (10 + 1) × pods.
Até 2026-09-09 esta tabela tinha uma coluna por formato de instalação e uma linha de tenants em cada célula, porque cada tenant abria o seu próprio pool HikariCP e a conta era (sharedPool + poolPorTenant × tenantsAtivos + 1) × pods. O pool por tenant foi removido: um pool só atende todo mundo, o search_path é aplicado quando a conexão é emprestada e a sessão é limpa quando ela volta. O cliente novo deixou de mover o número.
O teto chega com o pod — e isso é uma alavanca
Antes, redundância custava tenants: dois pods num db-f1-micro não sustentavam nem um cliente ativo, e o terceiro cliente derrubava a redundância de um db-g1-small. Agora o único fator é o pod, e o preço dele é fixo: mais um pod custa 11 conexões e traz mais um executor do Flowable junto. É por isso que escalar horizontalmente passou a ser o caminho para vazão de job — com o pool por tenant não era, porque cada pod multiplicava também a parcela dos tenants que ele servia.
O pool compartilhado tem piso, e o piso não é negociável
Um passo de IA segura duas conexões no pico (a dedução de cota é REQUIRES_NEW). Como todas as conexões saem do mesmo pool, ele precisa de app.flowable.executorMaxSize × 2 + 1 — 7 com o executor padrão. O chart recusa o render abaixo disso e a aplicação avisa no start, porque o erro que isso evita não é too many clients: é um tenant que simplesmente para de concluir etapas de IA, sem log e sem falha. O mesmo piso vale para app.tenantPool.maxSize, que é o teto de conexões que um tenant pode segurar dentro do pool compartilhado.
O que o pool compartilhado custa, medido e não suposto:
- ~1,8x no custo por consulta curta, numa bancada com quatro tenants alternando na mesma conexão (
SharedPoolCheckoutCostMeasurementTest, perfilperf). São ~51 µs de ida e volta a mais por empréstimo (aplicar osearch_pathe limpar a sessão) e ~15 µs de replanejamento, porque o PostgreSQL reanalisa a consulta quando osearch_pathmuda. Na escala de um passo de IA a diferença some: com chamadas de 1 s e 3 s a vazão sustentada é idêntica. - O anteparo é teto, não reserva. O pool por tenant garantia por construção que um tenant não consumia a conexão de outro. No lugar dele fica um teto por tenant dentro do pool compartilhado (
app.tenantPool.maxSize), exato por nó — mas um teto, não uma reserva: um tenant pode esperar por causa da rajada de outro.
Afinidade de tenant: um nó dono por tenant
app.tenantAffinity.enabled (desligada por padrão) dá a cada tenant um nó dono, que é o único a adquirir os jobs dele. O argumento de conexão da afinidade acabou junto com o pool por tenant: não existe mais parcela por tenant para deixar de multiplicar por pod, e o orçamento é idêntico com ela ligada e desligada. A linha de start diz isso em vez de calar sobre um benefício que não existe mais.
O que ela ainda compra, e o que pede em troca:
- Posse de job e localidade de plano. Um nó adquire só os jobs dos tenants dele, e o banco vê o mesmo tenant sempre pelo mesmo nó.
- O roteador na frente precisa distribuir por
X-Tenant-ID. O chart escrevenginx.ingress.kubernetes.io/upstream-hash-by: "$http_x_tenant_id"quandoapp.tenantAffinity.httpestá ligado, e recusa o render se oingressClassNamenão fornginx— outro controlador ignoraria a anotação em silêncio. - Um nó que cai devolve os tenants dele em 30–40 s (uma batida de heartbeat mais o TTL). Nesse intervalo os jobs desses tenants esperam; nada se perde e nada falha, mas eles atrasam. Detalhes, incluindo o que sai no log de cada nó, em
specs/core/tenant-node-affinity.md. - Instalação de um pod não muda em nada — com um destino só, o hash não tem o que distribuir.
Dimensionamento pela IA. Uma instalação executa 8 passos de IA ao mesmo tempo (AI_STEP_CORE_SIZE), e a vazão é core-pool-size da pool de IA × 60 ÷ latência_em_segundos. Com chamadas de ~20 s isso dá 24 passos por minuto — da instalação inteira, não por tenant. Acima disso a fila de 100 cresce e a espera de cada passo sobe; passando dela, a etapa é encerrada como falha e fica para reprocesso, em vez de ficar estacionada em silêncio. É aritmética: a bancada com latência injetada (specs/core/ai-step-concurrency.md) mediu o desenho antigo, de 2 vagas.
O que este desenho não resolve, e não vai resolver
Dito de uma vez, para ninguém descobrir em produção:
- Um tenant ocupado pode fazer outro esperar. A conexão não cresce mais com o número de clientes, mas o pool é um só: o teto por tenant impede que uma empresa consuma tudo, e não reserva conexão para quem está parado. Escrita concorrente pesada dentro de um mesmo tenant é o caso que este desenho não mediu — está registrado como limite conhecido em
specs/core/connection-pool-budget.md. - As tabelas do Flowable são compartilhadas. As 53
ACT_*vivem empublic, filtradas por tenant na consulta. Um tenant com histórico muito maior que os outros influencia o tempo de consulta de todos — o isolamento ali é de dados, não de desempenho. - A vazão de IA é do provedor. Nenhuma configuração nossa acelera uma chamada de 20 s. O que se escolhe é quantas cabem em paralelo — e desde 2026-09-09 cada uma segura uma thread da pool de IA, mas não uma conexão de banco durante a espera.
- Os números aqui são pior caso. Eles somam pools cheios ao mesmo tempo. Uma instalação real costuma ficar abaixo — e "costuma" não é base para dimensionar.
A receita, em três linhas
- Decida quantos pods a instalação vai rodar. O número de clientes não entra nesta conta.
- Escolha o tier pela tabela acima, com folga para a sua sessão de
psqle para o pico. - Só então decida réplicas: cada pod custa 11 conexões, sempre. Se faltar, o tier é a saída — não há mais o que separar por papel do lado das conexões.
Orçamento de conexões: só o pod multiplica
Existe um pool HikariCP por pod (spring.datasource.hikari.maximum-pool-size). Toda requisição de tenant sai dele: SchemaBoundDataSource aponta a conexão emprestada para tenant_<slug> e limpa a sessão quando ela volta. A demanda de conexões de uma instalação é:
(sharedPool + 1) × podsO + 1 × pods é o listener do bus de cluster (LISTEN flowia_cluster), que é o que faz uma atualização em tempo real publicada num pod chegar ao navegador conectado no outro. Custo fixo por pod, não por tenant.
Não há parcela por tenant. APP_DATABASE_EXPECTED_TENANTS continua existindo e continua saindo na linha de partida, mas não entra na soma: ele está ali para que o operador veja, lado a lado, o número de clientes que esperava e um orçamento que não se move com ele.
O chart templata isso, e sabe recusar
externalDatabase:
poolMaxSize: 10 # o ÚNICO pool de cada pod (piso: app.flowable.executorMaxSize × 2 + 1)
app:
tenantPool:
maxSize: 7 # teto que UM tenant pode segurar dentro daquele pool (TENANT_POOL_MAX_SIZE)
apiMaxSize: 3 # o mesmo teto num nó de papel `api`
database:
maxConnections: 200 # o max_connections do servidor. 0 = "não sei", e nada é conferido
expectedTenants: 8 # sai na linha de partida; não entra na conta
expectedPods: 0 # 0 deriva de backend.replicaCount + backend.worker.replicaCount
budgetEnforce: true # recusa o `helm template` em vez de recusar a conexãoCom budgetEnforce: true uma release que não cabe falha na renderização, imprimindo a soma e o teto, em vez de instalar e descobrir o limite como FATAL: sorry, too many clients already sob carga. expectedPods: 0 é o padrão e o certo: o chart conta os pods que ele mesmo cria, então o número que a plataforma recebe é o número que o cluster roda. Só preencha se houver pods de fora desta release compartilhando o banco.
A aplicação imprime a mesma conta, e também sabe recusar
O guard do chart pega a release antes de instalar. O nó pega o pod que foi configurado por fora dela — por backend.extraEnv, por manifesto editado à mão, ou por um banco cujo tier mudou embaixo dele. Em toda subida o backend imprime uma linha com o papel do nó e a decomposição, não só o total:
Orçamento de conexões (papel=all, pior caso): (10 compartilhado + 1 bus) = 11 por pod x 2 pod(s) = 22 conexões, contra APP_DATABASE_MAX_CONNECTIONS=50. Não há parcela por tenant: ...Os números vêm de APP_DATABASE_MAX_CONNECTIONS e APP_DATABASE_EXPECTED_PODS — que o chart preenche a partir de app.database.*. Passando do teto sai um aviso alto com o excedente e as alavancas que restaram; com APP_DATABASE_BUDGET_ENFORCE=true o nó recusa subir em vez de avisar. O padrão é false de propósito: recusar por padrão transformaria um número errado no values.yaml em indisponibilidade total, que é pior que o problema sendo relatado.
O número é pior caso, não consumo atual
É o teto que o pool compartilhado pode abrir; spring.datasource.hikari.minimum-idle decide quantas dessas conexões ficam de pé com o nó ocioso. Ler o aviso como "o banco já está cheio" leva a redimensionar a coisa errada.
As duas decisões, e quando cada uma é a certa
Quando a conta não fecha, sobraram duas saídas — e a que sumiu é a notícia:
| Saída | Quando é a certa | O que custa |
|---|---|---|
| Subir o tier do banco | Quando a instalação precisa de mais pods, por vazão de job ou por redundância | Dinheiro, e uma janela de manutenção no Cloud SQL |
Reduzir pods (backend.replicaCount, backend.worker.replicaCount) | Quando a vazão atual já basta e o que falta é conexão | Redundância, ou vazão de job — cada pod carrega um executor |
Não é mais uma alavanca de conexão. O nó api e o worker custam o mesmo: um pool cada. A separação continua valendo por isolamento de carga, não por conexão | — |
E a saída que deixou de ser necessária: subir o tier antes de um onboarding. O cliente novo não move mais o número; o que move é a decisão de rodar mais um pod, que é sua e não dele.
Encolher o pool tem piso
externalDatabase.poolMaxSize (e app.tenantPool.maxSize) não descem abaixo de app.flowable.executorMaxSize × 2 + 1. Abaixo disso os passos de IA se seguram e se esperam, e o sintoma não é erro: é o tenant parando de concluir etapa. Baixar o pool exige baixar FLOWABLE_EXECUTOR_MAX_SIZE junto — e aí a instalação executa menos passos de IA em paralelo.
Duas correções que valem conhecer:
- Até 2026-09-03 esta fórmula contava
sharedPooluma vez, e o pool compartilhado é por pod — cada pod é uma JVM com o seu. A versão antiga subestimava uma instalação de dois pods em um pool inteiro e lia "cabe" onde a verdade era "não cabe". - Até 2026-09-09 ela tinha um termo
poolPorTenant × tenantsAtivos. Ele saiu com o pool por tenant. Se você encontrar uma cópia de qualquer das duas fórmulas antigas, ela está errada. Registrado emspecs/core/connection-pool-budget.md.
O que o deploy gerenciado usa hoje
O deploy gerenciado roda dois pods contra um Cloud SQL db-g1-small (max_connections ≈ 50), com SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=10:
| pods | conta | contra 50 |
|---|---|---|
| 2 | (10 + 1) × 2 = 22 | cabe |
| 3 | (10 + 1) × 3 = 33 | cabe |
| 4 | (10 + 1) × 4 = 44 | cabe, com pouca folga para a sua sessão de psql |
| 5 | (10 + 1) × 5 = 55 | não cabe |
Em qualquer linha, com qualquer número de tenants. Até 2026-09-09 a mesma instância comportava dois clientes ativos e estourava no terceiro; e no db-f1-micro que ela era até 2026-09-05, o primeiro cliente já não cabia.
Trocar o tier reinicia a instância
gcloud sql instances patch --tier derruba as conexões abertas. Fazer isso com um cliente já na plataforma significa agendar indisponibilidade com ele. É por isso que a troca de tier é item de pré-onboarding e não de resposta a incidente.
TLS obrigatório e backup da instância gerenciada
Duas outras propriedades da instância gerenciada mudaram na mesma janela de 2026-09-05:
| Propriedade | Valor | O que isso obriga |
|---|---|---|
settings.ipConfiguration.sslMode | ENCRYPTED_ONLY | Conexão em texto claro é recusada pelo servidor, não rebaixada em silêncio. Quem conecta por IP precisa de sslmode=require na URL JDBC; o conector do Cloud SQL negocia TLS sozinho |
| Backup automatizado | diário, com recuperação a ponto no tempo | Há um ponto de retorno que não depende de alguém lembrar de rodar gcloud sql backups create |
Recuperação a ponto no tempo restaura a instância, não um cliente
Ela devolve o servidor inteiro a um instante: todos os schemas tenant_<slug> e o public, com as tabelas ACT_* e as tabelas por discriminador de todos os tenants juntos. Não existe "voltar o tenant acme para ontem" por esse caminho — usá-la para consertar um cliente reverte os outros junto.
O que existe para um tenant só é o par POST /api/admin/tenants/{id}/content-export / POST /api/admin/tenants/{id}/restore, que roda pg_dump -n tenant_<slug> e restaura sobre aquele schema. Isso não é um backup do tenant: o dump cobre só o schema dele, então instâncias em andamento, tarefas, jobs, histórico, agentes de IA, robôs, chaves de API e webhooks — que vivem em public — ficam de fora. Ver Multi-tenancy para quais tabelas ficam em cada lugar.
O piso do pool, e por que ele não desce sozinho
externalDatabase.poolMaxSize é a alavanca óbvia para caber em mais pods, e ela tem um piso que não é afinação, é correção. A dedução de cota de IA roda em transação própria (REQUIRES_NEW), que suspende a transação do job sem devolver a conexão dela e pede uma segunda. Cada passo de IA chega a segurar duas ao mesmo tempo:
piso = app.flowable.executorMaxSize × 2 + 1
= 3 × 2 + 1
= 7O + 1 é a conexão que precisa sobrar para o tráfego HTTP. O piso vale para o menor entre externalDatabase.poolMaxSize (o pool de onde o job tira conexão) e app.tenantPool.maxSize (o teto daquele tenant dentro dele): 20 conexões livres não ajudam um tenant cujo teto é 3, porque os passos dele se seguram entre si dentro do teto.
Abaixo do piso o sintoma não é erro, é o tenant parar de concluir etapa de IA: cada job segura a primeira conexão e espera a segunda, que só se libera quando algum outro job termina — e nenhum termina. O que aparece no log é timeout de conexão nas threads flowable-task-exec-*, sem nada que diga "o pool está pequeno demais".
O travamento em si começa mais cedo do que o piso sugere, e a distância é deliberada. Na bancada de specs/ai/quota-transaction-boundary.md o pool para de progredir em executorMaxSize e volta a progredir em executorMaxSize + 1 — 4 com o executor de hoje. Entre 4 e 7 nada trava: os passos apenas esperam conexão. Os três de margem cobrem o que a bancada não modela, o lock de linha na única linha de cota do tenant, que faz deduções concorrentes enfileirarem com cada chamador ainda segurando a sua conexão. O guard é escrito em × 2 + 1 porque é esse o pior caso; + 1 é só o ponto onde ele deixa de ser fatal.
Por isso encolher o pool sozinho é recusado na renderização do chart, com a conta na mensagem. Encolher os dois juntos continua permitido e é a configuração que compra um pod a mais:
externalDatabase:
poolMaxSize: 5 # baixa o custo do pod para 6 conexões
app:
flowable:
executorMaxSize: 2 # baixa o piso para 5
tenantPool:
maxSize: 5O que se perde nessa troca é a terceira thread, que o ThreadPoolExecutor só cria quando a fila de 100 já está cheia — a última folga antes de um job ser rejeitado em vez de apenas atrasado. A vazão sustentada não muda: ela é executorCoreSize, que continua 2.
app.tenantPool.apiMaxSize fica fora dessa conta. Um nó api não adquire job, então nenhuma thread dele abre a transação de cota e nenhuma segura duas conexões — é isso, e não uma tolerância a mais, que permite o pool de 3 ali.
A derivação completa e as configurações medidas estão em specs/ai/quota-transaction-boundary.md.
A role de banco: comum, nunca superusuária
A aplicação conecta com uma role de login comum. Ela é dona do schema public e tem CREATE no banco — o suficiente para as migrações, para o CREATE SCHEMA tenant_<slug> do provisionamento e para o pg_dump/psql do backup — e não tem SUPERUSER, BYPASSRLS, CREATEROLE, CREATEDB nem REPLICATION.
Isso importa por um motivo direto: uma role superusuária ignora permissão de schema e ignora Row Level Security, inclusive FORCE ROW LEVEL SECURITY. Com ela, nenhuma defesa de banco — atual ou futura — funciona, e todo o isolamento entre clientes passa a depender só do código da aplicação. SUPERUSER também habilita COPY … FROM PROGRAM, que executa comando no host do banco.
Confira a instalação com este comando, trocando o nome se você usa outro em externalDatabase.username:
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'flow-iagentic';Ou, direto do shell:
psql "$DATABASE_URL" -tAc \
"SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'flow-iagentic'"A resposta certa é flow-iagentic|f|f. Qualquer t significa que a instalação está sem barreira de banco entre clientes.
Para corrigir, como superusuário do banco:
ALTER ROLE "flow-iagentic" NOSUPERUSER NOBYPASSRLS NOCREATEROLE NOCREATEDB NOREPLICATION;
GRANT CONNECT, CREATE ON DATABASE "flow-iagentic" TO "flow-iagentic";
ALTER SCHEMA public OWNER TO "flow-iagentic";O backend confere isso sozinho em toda subida e imprime a linha:
Role de banco: flow-iagentic (rolsuper=false, rolbypassrls=false). Nenhuma defesa no nível do banco é ignorada por esta conexão.Quando a role tem privilégio demais, a mesma linha vira aviso e nomeia o atributo. APP_DATABASE_ROLE_ENFORCE decide o que acontece: false avisa e sobe — o padrão, porque numa instalação on-premise a role é decisão do DBA do cliente, e uma plataforma que não sobe é revertida, o que apaga o aviso junto; true recusa subir.
Onde cada padrão está, e por quê:
| Onde | Padrão | Motivo |
|---|---|---|
docker-compose.yml / .dev.yml | true | Ambiente que nós controlamos, e a correção é docker compose down -v |
docker-compose.prod.yml | false | Um volume existente não é reprovisionado; migre a role e então ligue |
Chart Helm (app.database.roleEnforce) | false | O chart é a entrega on-premise. helm/examples/production.yaml liga, depois de a consulta acima responder f e f |
O banco embutido do chart roda a aplicação como superusuário
postgresql.enabled=true sobe um StatefulSet cuja POSTGRES_USER é a própria role da aplicação — superusuária, por construção da imagem. Ele existe para uma avaliação começar em um comando, e o README do chart já diz que não tem backup, failover nem pooler. Isto é mais um motivo para não usá-lo em produção: use externalDatabase com uma role provisionada como acima.
Um volume de banco antigo não é reprovisionado
Os scripts de initdb só rodam num volume vazio. Um ambiente de desenvolvimento criado antes desta mudança continua conectando como superusuário — o backend recusa subir e diz por quê. docker compose down -v recria o banco do zero. É dado de desenvolvimento; num banco com dado real, use o ALTER ROLE acima em vez de apagar o volume.
Papéis de nó: quem responde HTTP e quem executa job
Uma imagem, dois trabalhos, escolhidos por configuração. O padrão é um nó fazendo os dois, que é o que toda instalação existente tem — a separação é opt-in e não muda nada para quem não pedir.
| Papel | Responde HTTP | Executa job Flowable | Tarefa agendada de cluster e CRON de robô | Migra o schema compartilhado | TENANT_POOL_MAX_SIZE padrão |
|---|---|---|---|---|---|
all | sim | sim | no nó eleito | sim | 7 |
api | sim | não | não | não | 3 |
worker | só /actuator | sim | no nó eleito | sim | 7 |
A variável é APP_NODE_ROLE, e o chart a escreve nos dois Deployments. Um nó que sobe sem ela é all — exatamente o comportamento de sempre, e é por isso que nenhuma instalação existente muda ao atualizar. Papel escrito errado recusa a subir, em vez de virar all e servir sob um papel que não é o configurado.
backend:
role: api # este Deployment só responde HTTP
replicaCount: 2
worker:
enabled: true # e este só executa job
replicaCount: 2
persistence:
enabled: false # quatro pods não montam um volume ReadWriteOnceUm nó api continua criando job: iniciar uma instância, completar uma tarefa e gravar uma variável escrevem em ACT_RU_JOB. Ele só nunca os adquire.
"Não roda tarefa agendada" quer dizer tarefa de cluster. O que separa uma da outra é o lease: dezesseis das dezenove tarefas agendadas disputam um lease em job_leases antes de rodar, e num nó api essa disputa é negada na hora, sem nem ir ao banco. As três que não disputam continuam rodando em todo nó de propósito, porque reparam ou publicam estado daquele nó: a reconciliação de delegates, a amostragem de saturação da fila de IA e o heartbeat de membership do nó (ClusterMembershipService). ScheduledWorkIsLeasedOrPerNodeTest mantém essa lista de três e reprova um @Scheduled novo que não faça nem uma coisa nem outra.
"Não migra" é o conjunto compartilhado no boot. O Flyway de schema de tenant continua ligado num nó api — é ele que cria o schema de um tenant novo e o migra na primeira requisição dele, e desligar isso entregaria um schema vazio dizendo que deu certo.
Quando separar. Não é sobre HTTP. A vazão que acaba é a de passo de IA: uma etapa fica segundos a minutos bloqueada no modelo segurando uma thread e uma conexão de tenant, e é esse número que cresce adicionando worker. Se o gargalo for requisição HTTP, backend.replicaCount já resolve.
Quem migra. Os papéis que executam job (all e worker) rodam o Flyway do schema compartilhado na partida; api nunca roda. Por isso backend.role: api sem backend.worker.enabled: true é recusado na renderização: ninguém executaria job e ninguém migraria, e nenhuma das duas falhas avisa — o trabalho simplesmente se acumula sem ser adquirido. A justificativa dessa escolha, e a alternativa que foi descartada (um Job de pre-upgrade), estão em specs/core/job-worker-nodes.md → Who migrates.
Nada ordena os dois Deployments
O Kubernetes sobe api e worker ao mesmo tempo. Um pod api pode subir enquanto a migração ainda roda; o certo é ele recusar readiness até o schema ser o que ele espera, e essa metade ainda não está implementada na aplicação. Até lá, num upgrade que traz migração, prefira subir a release com --wait e conferir o rollout do worker antes de mandar tráfego.
Segredos
Deixe secrets.jwtSecret e secrets.encryptionKey vazios e o chart os gera, relendo o Secret existente no upgrade para nunca rotacionar por acidente.
Guarde o APP_ENCRYPTION_KEY
Ele descriptografa credenciais já gravadas no banco: segredo de SSO, senha de SMTP, chave do provedor de IA (ai_models, rag_config), segredo de assinatura dos webhooks e toda variável de tenant marcada como secreta. Perdê-lo não falha na hora — falha horas depois, na primeira integração que tenta ler a própria senha. O Secret carrega helm.sh/resource-policy: keep para o helm uninstall não levá-lo junto, e as notas da instalação imprimem o comando de backup.
O que fica cifrado no banco
Credenciais de terceiros não são guardadas em texto claro. As colunas abaixo são cifradas com AES-256-GCM usando o APP_ENCRYPTION_KEY, na escrita e na leitura, de forma transparente para a aplicação:
| Coluna | O que guarda |
|---|---|
ai_models.api_key, rag_config.api_key | Credencial do cliente com o provedor de IA |
webhooks.secret | Chave que assina o HMAC das entregas |
tenant_variables.value_text | Variáveis do tenant, incluindo as marcadas como secretas |
sso_connections.client_secret, configurações de e-mail | Credenciais de SSO e do servidor de e-mail |
ACT_RU_VARIABLE.TEXT_, ACT_HI_VARINST.TEXT_ (tipo encryptedSecret) | Senha digitada num campo de formulário do tipo Senha |
No boot a aplicação cifra qualquer valor que ainda esteja em claro e registra quantos foram; o número esperado é zero.
A última linha é a única que expira: a senha digitada num formulário some das tabelas de execução quando a instância termina, e uma varredura noturna apaga a cópia do histórico depois de FORM_PASSWORD_HISTORY_RETENTION_DAYS dias (7 por padrão). Ela é a única credencial que não tem casa no cofre — nasce no meio de um processo e pertence a uma instância só.
O corpo de requisição nunca vai para o log
O log de requisições HTTP registra a partir do status 400 e nunca escreve o corpo enviado, em rota nenhuma. Isso não depende de marcar campo nem de listar caminho: um envio de formulário que falha carrega os valores digitados no corpo, e um deles pode ser uma senha. O que continua no log é método, caminho, query (com password e token ofuscados), cabeçalhos (com Authorization e X-API-Key ofuscados), status e o corpo da resposta — que é onde está o erro de validação que explica a falha.
Credenciais que o sistema apenas confere não são cifradas, e sim guardadas como hash, que é mais forte: senha de usuário, chave de API, token de sessão e token de e-mail. A diferença é se o valor original precisa voltar: a chave do provedor precisa ser reenviada a ele, uma senha nunca.
Cifra em repouso protege o banco, não a aplicação
Um dump de backup, uma réplica de leitura ou um acesso direto ao banco não revelam essas credenciais. Um processo comprometido da própria aplicação, sim — ele tem a chave e decifra por construção.
Recomendado: SECRETS_INJECT_INTO_PROCESS_VARIABLES=false
Com true (o padrão), variável secreta é copiada para as variáveis da instância e o Flowable a grava em texto claro no runtime e no histórico. Com false, ela não entra na instância — as expressões usam secrets.get(execution, 'chave'), e nenhuma credencial fica nas tabelas do motor. Ver Variáveis do Tenant.
Para gerenciar os segredos por fora, use secrets.existingSecret com as chaves: DB_PASSWORD, JWT_SECRET, APP_ENCRYPTION_KEY, S3_ACCESS_KEY, S3_SECRET_KEY.
GEMINI_API_KEY é opcional — inclua para configurar um modelo de IA inicial; sem ela a plataforma sobe e as funcionalidades de IA falham na chamada, com 409 e a frase dizendo onde cadastrar um modelo. Não a inclua vazia: variável vazia é definida, não ausente, e o Spring AI recusa subir contra uma. Era assim que o backend entrava em CrashLoopBackOff enquanto o helm install dizia que tinha dado certo.
Provedor de IA
O padrão é Gemini pela API compatível com OpenAI, mas qualquer endpoint compatível serve — vLLM, gateway interno, Ollama atrás de proxy:
ai:
baseUrl: http://llm-gateway.internal:8000/v1/
chatModel: qwen2.5-72b-instruct
embeddingModel: bge-m3
embeddingDimensions: 1024embeddingDimensions dimensiona a coluna do pgvector: mudar depois de indexar documentos exige reindexar toda a base de conhecimento.
Cluster sem internet
cd helm
make mirror REGISTRY=registry.corp.internal/flowia TAG=1.0.0
helm install flowia ./flowia -n flowia --create-namespace -f examples/airgapped.yamlNada mais sai para fora: não há subchart remoto nem init container que baixe qualquer coisa.
Operação
cd helm
make status # release, pods, services, volumes
make logs # acompanha o backend
make smoke # helm test: health do backend + SPA de dentro do cluster
make upgrade VALUES=meu.yamlDepois de instalar, entre como SUPER_ADMIN e cole a licença da instalação em Global (Super Admin) → Licença. A verificação é offline: não há servidor de ativação nem necessidade de saída para a internet.
Testes do chart
cd helm && make testAs asserções só precisam do helm — sem cluster, sem plugin, sem rede. Renderizam o chart sob vários conjuntos de valores e conferem formato dos objetos, ligação do banco, rotas do ingress, modos de storage, tratamento de segredos e cada guarda que precisa falhar na renderização em vez de no pod.
O CI roda isso a cada push, mais kubeconform -strict contra os schemas reais da API do Kubernetes e o helm package, publicando o .tgz como artefato do build.
Flyway — Migrações de banco
Dois conjuntos, aplicados em momentos diferentes:
| Caminho | Escopo | Quando roda |
|---|---|---|
src/main/resources/db/migration/ | Schema public (compartilhado) | No start, e só nos papéis que executam job (all e worker) — um nó api assume o schema já migrado |
src/main/resources/db/tenant/ | Schema de cada tenant | No provisionamento do tenant; na primeira requisição daquele tenant em cada nó; e na varredura de fundo para o tenant sem tráfego |
Nenhum nó migra tenant nenhum no start
Desde 2026-08-28 o tempo de boot deixou de crescer com a quantidade de tenants. A consequência para quem faz deploy é que a migração de um schema de tenant chega pela primeira requisição dele depois do release, ou pela varredura — não pela subida do pod. Ver Multi-tenancy.
O caminho do tenant é db/tenant/, não db/migration/tenant/
O Flyway compartilhado aponta para classpath:db/migration e varre tudo que estiver aninhado ali. Uma migração de tenant colocada em db/migration/tenant/ seria executada também no schema public — exatamente o oposto do que ela deveria fazer. Veja Multi-tenancy para o mecanismo completo.
Ensaiar a migração sobre uma cópia antes de subir
Migração que só cria tabela não precisa de ensaio. Migração que reescreve linha existente precisa: o número de linhas afetadas — e as que ela não vai conseguir converter — só aparece sobre os dados de verdade. Foi o caso de V0097 (o tenant_id_ das definições passa do UUID para o slug, em quatro tabelas do Flowable) e de V0098 (robots.parameters vira jsonb, e o que não for objeto JSON é anulado).
./deploy/rehearse-migration.sh --dump backup.sqlO script sobe um Postgres descartável, restaura o dump, imprime o estado antes, aplica as migrações pendentes e imprime o estado depois. O container é removido ao final, com ou sem erro, e o dump não é alterado. Aceita pg_dump em texto ou no formato próprio, e --migration V0097 limita quais rodar.
O relatório responde três perguntas: quantas linhas cada tabela move, quantas não casam com tenant nenhum e portanto ficam como estão, e quantos robôs perderiam os parâmetros por não serem um objeto JSON. Esse último número é o que vale uma conversa antes do deploy: são robôs que vão precisar de reconfiguração pela tela.
Super Admin inicial
O usuário inicial é criado pela migração V0001__init.sql, que roda sozinha no primeiro startup — não há passo manual a executar.
| Campo | Valor |
|---|---|
admin@flowiagentic.local | |
| Senha | Admin@1234 |
| Papel | SUPER_ADMIN |
O e-mail não tem hífen (flowiagentic, não flow-iagentic) — o nome do banco de dados tem, e é daí que vem a confusão. Tentar entrar com a versão hifenizada devolve credencial inválida.
Altere imediatamente em produção
A senha padrão é conhecida e deve ser alterada antes de expor a aplicação.
Build de produção (Frontend)
cd frontend
npm run buildOs arquivos estáticos ficam em frontend/dist/ e podem ser servidos por qualquer servidor web (nginx, Apache, CDN).
CI/CD
O CI roda em GitHub Actions, com os workflows em .github/workflows/. Não há azure-pipelines.yml no repositório.
| Workflow | O que faz |
|---|---|
ci.yml | Validação em todo push para master/main e em todo pull request |
deploy-gcp-backend.yml | Deploy do backend no GCP |
security.yml | Semgrep e OSV a cada push; Dependency-Check e SBOM duas vezes por semana |
deploy-gcp-frontend.yml | Deploy do frontend no GCP |
deploy-gcp-docs.yml | Deploy desta documentação |
deploy-gcp-landing.yml | Deploy do site de marketing |
O ci.yml tem dois jobs paralelos:
- Backend (Java 21) — sobe um Postgres
pgvector/pgvector:pg16como service e rodamvn verify, excluindoS3StorageProviderTesteAttachmentServiceTest, que dependem de armazenamento externo. Omvn verifynão sobe a aplicação e não gera oopenapi.json: esse artefato vive no profileopenapi, descrito em API REST. - Frontend (TypeScript) —
npm run build,npm run test:run,npm run i18n:checkenpm run lint:check. O passo de tipos é a build de produção inteira, nãotsc --noEmit: sozinho ele ignora as project references e deixa passar erro que a imagem de produção pega.
O i18n:check é bloqueante: uma string nova sem tradução em pt-BR, en e es reprova o build.
O lint:check é bloqueante de outro jeito. O ESLint tem dívida antiga (368 achados), e reprovar todo build por causa dela só ensinaria a ignorar o passo; então a contagem por regra fica congelada em frontend/scripts/lint-baseline.json. Dívida velha passa; regra que cresce reprova; e regra que ninguém violava tem teto zero, de modo que uma categoria nova de problema não entra em silêncio. Corrigiu achados? npm run lint:check -- --update-baseline trava o ganho — o comando se recusa a subir um teto, só a baixá-lo.