Skip to content

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.yml usa a imagem pgvector/pgvector:pg16; um PostgreSQL 16 sem a extensão falha na criação do vector_store no start

1. Subir o banco de dados

bash
docker compose up -d db

Isso inicia apenas o container do PostgreSQL na porta 5432.

2. Executar o backend

bash
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=true

O 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

bash
cd frontend
npm install
npm run dev

O frontend estará disponível em http://localhost:3000 (vite.config.tsserver.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:

bash
touch backend/core/target/classes/.reloadtrigger

O 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ávelDescriçãoPadrão (dev)
DB_HOSTHost do PostgreSQLlocalhost
DB_PORTPorta do PostgreSQL5432
DB_NAMENome do bancoflow-iagentic
DB_USERNAMEUsuário do bancoflow-iagentic
DB_PASSWORDSenha do bancoflow-iagentic
JWT_SECRETChave secreta para assinar JWTs
APP_ENCRYPTION_KEYCifra 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_VARIABLESCom 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_POLICYCom deny, agente sem lista de destinos não chama fora — recomendadoallow
AI_STEP_CORE_SIZEA 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 cita8
AI_STEP_QUEUE_CAPACITYEtapas de IA que esperam vaga antes de a pool recusar. Recusada, a etapa é encerrada como falha e fica disponível para reprocesso100
AI_STEP_PROVIDER_TIMEOUT_SECONDSQuanto a etapa espera o provedor antes de desistir. O lease da reserva é o dobro disso120
FLOWABLE_EXECUTOR_CORE_SIZEJobs 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 IA2
FLOWABLE_EXECUTOR_MAX_SIZETeto 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 é capacidade3
JWT_EXPIRATION_MSExpiração do access token (ms)3600000 (1h)
SPRING_PROFILES_ACTIVEPerfil Spring ativodev
APP_NODE_ROLEall, 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_ENABLEDCom 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_HTTPDeclara 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 sozinhafalse
APP_DATABASE_MAX_CONNECTIONSmax_connections do servidor de banco. 0 significa "não informado" e desliga a comparação0
APP_DATABASE_EXPECTED_TENANTSQuantos tenants ativos a instalação espera. Entra na conta do teto de conexões0
APP_DATABASE_EXPECTED_PODSQuantos pods de backend a instalação espera1
APP_DATABASE_BUDGET_ENFORCECom true, o nó recusa subir quando a conta passa de APP_DATABASE_MAX_CONNECTIONS, em vez de só avisarfalse
APP_DATABASE_ROLE_ENFORCECom 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_RESPONSEFalha a tarefa quando o agente devolve resposta vazia, em vez de gravar um valor em brancotrue
AI_LOG_RAW_RESPONSE_ON_EMPTYLoga a resposta bruta do provedor quando ela chega sem nenhum candidato, para diagnosticar o motivo do bloqueiotrue
AI_TASK_FAILURE_RETENTION_DAYSDias que um registro de falha de agente fica guardado antes da limpeza diária90
FORM_PASSWORD_HISTORY_RETENTION_DAYSDias que a senha digitada num campo de formulário fica no histórico, já cifrada, antes da varredura noturna apagá-la7
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZEO ú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_SIZETeto 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 piso7
APP_STORAGE_TYPES3 ou LOCAL. Fora dos perfis dev e test a aplicação recusa subir com LOCALLOCAL (só serve em dev)
APP_STORAGE_S3_ENDPOINTEndpoint compatível com S3. Vazio significa a própria AWS S3
APP_STORAGE_S3_REGIONRegião do bucketus-east-1
APP_STORAGE_S3_BUCKETBucket onde ficam anexos, fotos de perfil e JARs de robô. Obrigatório fora de dev/testflow-iagentic
APP_STORAGE_S3_ACCESS_KEYChave de acesso. Obrigatória fora de dev/test
APP_STORAGE_S3_SECRET_KEYChave 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.

bash
# Build e start do cluster (Node padrão)
docker compose up --build -d

Para 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/NOTIFY do 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çoContainerPorta
Banco de dadosflow-iagentic-db5432
Backendflow-iagentic-app8080

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

Kubernetes1.23+
Helm3.8+
Ingress controllerqualquer um — ou nenhum, com ingress.enabled=false
PostgreSQL16 com pgvector, embutido ou externo
Object storageobrigató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

bash
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:80

Sobe 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

bash
helm install flowia ./flowia -n flowia --create-namespace \
  -f examples/production.yaml --wait --timeout 20m

helm/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

bash
# 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-db

O 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.

bash
--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 dentro

storage.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.typeO volume em /datapersistence.enabled=false
S3 (padrão)é só cache dos JARs de robôpermitido — vira emptyDir
LOCAL (dev/teste)é o armazenamento dos anexosrecusado 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:

  1. Crie o bucket no object storage que o cliente já opera, e uma chave que leia, escreva e apague dentro dele.
  2. Copie os arquivos do volume. Eles estão em persistence.mountPath/data/attachments por padrão — no formato <diretório>/<arquivo>, que é exatamente a chave S3 que o S3StorageProvider teria gravado. Um mc mirror ou aws s3 sync de /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.
  3. Reescreva attachments.storage_path. É o passo fácil de esquecer e impossível de pular: com LOCAL a coluna guarda um caminho absoluto (/data/attachments/<diretório>/<arquivo>), com S3 ela guarda a chave (<diretório>/<arquivo>). attachments é tabela por schema de tenant, então o update roda uma vez em cada tenant_<slug>, não uma vez só.
  4. 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.mdHow 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.

LimiteO que o fixaComo se manifesta
Conexões de banco(sharedPool + 1) × podsFATAL: sorry, too many clients already numa requisição de usuário
Concorrência de passo de IAAI_STEP_CORE_SIZE (8 por padrão), por instalaçãofila de etapas de IA crescendo; a espera aumenta, e a etapa recusada por fila cheia vai para reprocesso
HTTPCPU e memória do podraramente é 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_connectionsTier típico no Cloud SQLpods que cabem, com qualquer número de tenants
25db-f1-micro2
50db-g1-small — o do deploy gerenciado4
1001 vCPU / 3,75 GB9
2002 vCPU / 7,5 GB18
4004 vCPU / 15 GB36

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, perfil perf). São ~51 µs de ida e volta a mais por empréstimo (aplicar o search_path e limpar a sessão) e ~15 µs de replanejamento, porque o PostgreSQL reanalisa a consulta quando o search_path muda. 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 escreve nginx.ingress.kubernetes.io/upstream-hash-by: "$http_x_tenant_id" quando app.tenantAffinity.http está ligado, e recusa o render se o ingressClassName não for nginx — 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 em public, 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

  1. Decida quantos pods a instalação vai rodar. O número de clientes não entra nesta conta.
  2. Escolha o tier pela tabela acima, com folga para a sua sessão de psql e para o pico.
  3. 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) × pods

O + 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

yaml
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ão

Com 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ídaQuando é a certaO que custa
Subir o tier do bancoQuando a instalação precisa de mais pods, por vazão de job ou por redundânciaDinheiro, 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ãoRedundância, ou vazão de job — cada pod carrega um executor
Separar papéisNã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 sharedPool uma 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 em specs/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:

podscontacontra 50
2(10 + 1) × 2 = 22cabe
3(10 + 1) × 3 = 33cabe
4(10 + 1) × 4 = 44cabe, com pouca folga para a sua sessão de psql
5(10 + 1) × 5 = 55nã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:

PropriedadeValorO que isso obriga
settings.ipConfiguration.sslModeENCRYPTED_ONLYConexã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 automatizadodiário, com recuperação a ponto no tempoHá 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
     = 7

O + 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:

yaml
externalDatabase:
  poolMaxSize: 5       # baixa o custo do pod para 6 conexões
app:
  flowable:
    executorMaxSize: 2   # baixa o piso para 5
  tenantPool:
    maxSize: 5

O 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:

sql
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'flow-iagentic';

Ou, direto do shell:

bash
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:

sql
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ê:

OndePadrãoMotivo
docker-compose.yml / .dev.ymltrueAmbiente que nós controlamos, e a correção é docker compose down -v
docker-compose.prod.ymlfalseUm volume existente não é reprovisionado; migre a role e então ligue
Chart Helm (app.database.roleEnforce)falseO 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.

PapelResponde HTTPExecuta job FlowableTarefa agendada de cluster e CRON de robôMigra o schema compartilhadoTENANT_POOL_MAX_SIZE padrão
allsimsimno nó eleitosim7
apisimnãonãonão3
worker/actuatorsimno nó eleitosim7

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.

yaml
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 ReadWriteOnce

Um 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.mdWho 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:

ColunaO que guarda
ai_models.api_key, rag_config.api_keyCredencial do cliente com o provedor de IA
webhooks.secretChave que assina o HMAC das entregas
tenant_variables.value_textVariáveis do tenant, incluindo as marcadas como secretas
sso_connections.client_secret, configurações de e-mailCredenciais 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:

yaml
ai:
  baseUrl: http://llm-gateway.internal:8000/v1/
  chatModel: qwen2.5-72b-instruct
  embeddingModel: bge-m3
  embeddingDimensions: 1024

embeddingDimensions dimensiona a coluna do pgvector: mudar depois de indexar documentos exige reindexar toda a base de conhecimento.

Cluster sem internet

bash
cd helm
make mirror REGISTRY=registry.corp.internal/flowia TAG=1.0.0
helm install flowia ./flowia -n flowia --create-namespace -f examples/airgapped.yaml

Nada mais sai para fora: não há subchart remoto nem init container que baixe qualquer coisa.

Operação

bash
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.yaml

Depois 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

bash
cd helm && make test

As 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:

CaminhoEscopoQuando 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 tenantNo 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).

bash
./deploy/rehearse-migration.sh --dump backup.sql

O 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.

CampoValor
E-mailadmin@flowiagentic.local
SenhaAdmin@1234
PapelSUPER_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)

bash
cd frontend
npm run build

Os 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.

WorkflowO que faz
ci.ymlValidação em todo push para master/main e em todo pull request
deploy-gcp-backend.ymlDeploy do backend no GCP
security.ymlSemgrep e OSV a cada push; Dependency-Check e SBOM duas vezes por semana
deploy-gcp-frontend.ymlDeploy do frontend no GCP
deploy-gcp-docs.ymlDeploy desta documentação
deploy-gcp-landing.ymlDeploy do site de marketing

O ci.yml tem dois jobs paralelos:

  1. Backend (Java 21) — sobe um Postgres pgvector/pgvector:pg16 como service e roda mvn verify, excluindo S3StorageProviderTest e AttachmentServiceTest, que dependem de armazenamento externo. O mvn verify não sobe a aplicação e não gera o openapi.json: esse artefato vive no profile openapi, descrito em API REST.
  2. Frontend (TypeScript)npm run build, npm run test:run, npm run i18n:check e npm run lint:check. O passo de tipos é a build de produção inteira, não tsc --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.

Flowi Agentic — Plataforma de Gestão de Processos com IA