Startups

Como colocar um SaaS multi-tenant em produção na Anthares com uma equipe de duas a cinco pessoas e prazo de semanas, sem herdar dívida de infraestrutura que só aparece no primeiro pico de tráfego.

Guia Atualizado em julho de 2026 Leitura: ~22 min

Introdução

Este guia descreve o caminho de um SaaS que sai de um repositório vazio para o primeiro cliente pagante nos serviços gerenciados da Anthares, num cenário de equipe pequena, sem ninguém dedicado a infraestrutura em tempo integral. As escolhas recomendadas otimizam para reversibilidade: um caminho que custa 15% mais de CPU mas se desfaz em uma tarde vale mais que um caminho ótimo que exige migração de dados de fim de semana.

A diferença entre este documento e a página de Enterprise é o horizonte de planejamento: lá se dimensiona para uma carga conhecida e um contrato assinado, aqui para uma carga desconhecida com margem de erro de duas ordens de grandeza. Por isso tudo é escrito em torno de dois números: o teto do que cada componente aguenta na configuração inicial, e o sinal mensurável que indica proximidade desse teto.

Escopo

O guia cobre uma aplicação web transacional com carga de leitura dominante (proporção típica de 20 leituras para 1 escrita). Cargas analíticas, ingestão contínua de eventos e pipelines de agregação estão em Data Platforms.

Reversibilidade

Nenhuma decisão do estágio inicial deve exigir downtime maior que 5 minutos para ser desfeita.

Limites explícitos

Cada componente tem um teto documentado e uma métrica que mede a distância até ele.

Custo por estágio

A conta cresce em degraus previsíveis, não linearmente com o tráfego.

Visão geral

A Anthares expõe sete serviços que um SaaS inicial consome: a44-pg (Postgres gerenciado), a44-run (execução de containers), a44-store (object storage compatível com S3), a44-auth (identidade e sessões), a44-gate (gateway de API com rate limit), a44-obs (métricas, logs e traces) e a44-bots (agentes de IA com acesso controlado a dados). Todos compartilham um mesmo objeto de projeto, e o projeto é a fronteira de faturamento, de rede privada e de permissões.

Um projeto contém um ou mais environments. Para equipe pequena, use exatamente dois: prod e preview. Um staging permanente cria deriva de configuração que ninguém terá tempo de reconciliar; preview é efêmero, criado por pull request e destruído no merge, com configuração sempre derivada de prod.

Bordaa44-gate: TLS, roteamento por hostname, rate limit por tenant, cache de resposta para GETs idempotentes.
Aplicaçãoa44-run: containers stateless, autoscaling por concorrência, workers de fila no mesmo binário.
Identidadea44-auth: emissão de JWT com claim de tenant, sessões, chaves de API por organização.
Dadosa44-pg com RLS ativa; a44-store para binários; ambos na rede privada do projeto.
Observabilidadea44-obs: retenção de 30 dias, alertas por regra, trace amostrado a 10%.
ServiçoUnidade de cobrançaTeto do plano inicialSintoma ao estourar
a44-pgvCPU-hora + GB armazenados2 vCPU / 100 GB / 120 conexõestoo many connections no pool
a44-runGB-segundo de container ativo8 instâncias simultâneasFila de admissão, p99 sobe em degrau
a44-storeGB-mês + requisições500 GB / 5M req por mêsHTTP 429 no upload
a44-gateRequisições processadas2.000 req/s por hostnameHTTP 429 com Retry-After
a44-obsGB de log ingerido50 GB/mês, retenção 30 diasAmostragem forçada de logs INFO

Primeiros passos

O provisionamento inicial é feito pela CLI a44. O comando a44 init cria o projeto, a rede privada, um banco Postgres, um bucket e as credenciais de deploy, e escreve um arquivo anthares.yaml no repositório. Esse arquivo é a fonte de verdade: qualquer alteração feita pelo painel web que não esteja refletida nele será sobrescrita no próximo a44 apply. Trate o painel como leitura, e o YAML como escrita.

O provisionamento completo leva de 3 a 6 minutos, dominado pela criação da instância Postgres. Se falhar no meio, o comando é idempotente: rodar de novo reaproveita os recursos já criados comparando project.slug. O slug é imutável — renomear exige projeto novo e migração de dados.

  1. Instalar e autenticar

    a44 login abre o navegador e grava um token de 90 dias em ~/.a44/credentials. Em CI, use A44_TOKEN com um token de serviço de escopo restrito.

  2. Criar o projeto

    a44 init --template saas-ts gera o esqueleto com migrations, RLS já configurada e um health check em /healthz.

  3. Aplicar a infraestrutura

    a44 apply reconcilia o YAML com o estado real e imprime um plano antes de executar. Sem --yes, pede confirmação para qualquer operação destrutiva.

  4. Primeiro deploy

    a44 deploy --env prod constrói a imagem, roda as migrations e faz rollout gradual. Duração média de 90 a 140 segundos.

  5. Verificar

    a44 status --env prod deve mostrar healthy em todos os componentes e latência p50 abaixo de 80 ms no endpoint de saúde.

terminal
npm i -g @anthares44/cli
a44 login
a44 init --template saas-ts --region sa-east-1 --slug acme-saas
a44 apply --yes
a44 deploy --env prod
anthares.yaml
project:
  slug: acme-saas
  region: sa-east-1

database:
  plan: pg-dev-2       # 2 vCPU, 4 GB RAM, 100 GB
  pooler:
    mode: transaction
    max_client_conn: 400
    default_pool_size: 20

runtime:
  image: ./Dockerfile
  min_instances: 1     # 0 economiza, mas adiciona cold start de ~900 ms
  max_instances: 8
  concurrency: 40
  health_path: /healthz

storage:
  buckets:
    - name: tenant-uploads
      public: false
      lifecycle_days: 365
min_instances: 0 tem um custo escondido

Escalar a zero economiza cerca de R$ 40/mês no estágio inicial, mas o cold start de 900 ms atinge justamente o primeiro visitante depois de um período ocioso — frequentemente um avaliador do produto. Mantenha min_instances: 1 em prod e 0 em preview.

Arquitetura

A arquitetura recomendada no estágio inicial é um monólito modular em um único container, com os workers de fila no mesmo binário sob uma flag de processo. Não é concessão temporária: até cerca de 30.000 usuários ativos mensais e 15 req/s em média, dividir em serviços separados aumenta o p95 por saltos de rede internos e o custo operacional, sem ganho de throughput.

A separação que vale a pena desde o dia um é entre caminho síncrono e caminho diferido. Toda operação que envolve um terceiro — envio de e-mail, geração de PDF, chamada a um modelo de IA, webhook de saída — vai para a fila com uma linha na tabela jobs. O motivo é concreto: um provedor externo com p99 de 8 segundos amarra um worker de HTTP inteiro, e com concorrência 40 bastam 40 chamadas lentas simultâneas para saturar a instância.

ClienteSPA / SDK
a44-gateTLS · rate limit
Appmonólito modular
a44-pgRLS por tenant
Appenfileira job
jobstabela Postgres
WorkerSKIP LOCKED
Terceirosretry com backoff
Fila no Postgres, não em um broker

Uma tabela jobs com FOR UPDATE SKIP LOCKED sustenta com folga 500 jobs/s em pg-dev-2. Introduzir um broker dedicado antes disso adiciona um sistema com estado próprio, backup próprio e modo de falha próprio — três coisas que a equipe ainda não tem capacidade de operar.

Backend

O container cumpre três contratos com a44-run: responder 200 em /healthz em menos de 2 segundos, escutar na porta de PORT, e terminar graciosamente após SIGTERM dentro da janela de 25 segundos que antecede o SIGKILL. O terceiro é o mais violado: sem drenagem, cada deploy descarta requisições em voo e produz um pico de 502 a cada release.

A configuração de concorrência interage diretamente com o pool de conexões do banco. A regra prática é default_pool_size × max_instances ≤ 0,8 × max_connections do Postgres. Com pg-dev-2 (120 conexões), 8 instâncias e pool 20 por instância chegaríamos a 160 conexões — acima do limite. A resposta correta não é aumentar o plano do banco, e sim apontar a aplicação para o pooler em modo transaction, que multiplexa 400 conexões de cliente sobre 20 conexões reais.

src/server.ts
import { createServer } from 'node:http';
import { pool } from './db';

const server = createServer(app);
server.listen(Number(process.env.PORT ?? 8080));

process.on('SIGTERM', async () => {
  server.close();                             // para de aceitar novas conexões
  await new Promise(r => setTimeout(r, 3000)); // drena o gate
  await pool.end();                           // devolve conexões ao pooler
  process.exit(0);
});
app/main.py
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app):
    yield
    await asyncio.sleep(3)   # janela de drenagem do a44-gate
    await pool.close()

app = FastAPI(lifespan=lifespan)
# uvicorn --timeout-graceful-shutdown 20 --workers 1
VariávelPadrãoDescrição
PORT8080Injetada pelo runtime. Ignorá-la faz o health check falhar e o rollout reverter.
A44_DATABASE_URLAponta para o pooler. A URL direta está em A44_DATABASE_URL_DIRECT e só deve ser usada por migrations.
A44_ROLEwebweb serve HTTP; worker consome a fila. Mesma imagem, comando diferente.
A44_DEPLOY_IDgeradoIdentificador do release, propagado para logs e traces. Use-o para correlacionar regressões.
A44_SHUTDOWN_GRACE25Segundos até o SIGKILL. Aumentar acima de 60 atrasa cada deploy no mesmo valor.

Banco de dados

O schema mínimo viável de um SaaS multi-tenant tem quatro tabelas: tenants, users, memberships e a entidade central do produto. A tentação é ligar users direto a tenants por chave estrangeira, e essa é a decisão que mais custa depois: quando um cliente pede que a mesma pessoa acesse duas organizações, a migração toca todas as queries do sistema. A tabela memberships custa 20 minutos no dia um.

Multi-tenancy: por linha ou por schema

Isolamento por linha significa uma coluna tenant_id em cada tabela, com Row Level Security aplicando o filtro. Isolamento por schema significa um schema Postgres por cliente, com as mesmas tabelas replicadas. Por linha é o padrão recomendado abaixo de 5.000 tenants: um único conjunto de migrations, um único plano de query cacheado, e o custo de índice é apenas a coluna extra no prefixo. Por schema só se justifica quando há exigência contratual de isolamento físico ou quando um único tenant representa mais de 30% do volume.

O ponto de ruptura do isolamento por schema é concreto e mensurável: acima de aproximadamente 2.000 schemas, o catálogo do Postgres (pg_class, pg_attribute) cresce a ponto de o planner gastar dezenas de milissegundos por conexão nova, e cada migration passa a levar horas porque roda 2.000 vezes. Não existe caminho de volta barato: consolidar schemas em linhas exige reescrever chaves primárias que podem colidir.

CritérioPor linha (RLS)Por schema
Tenants suportados~500.000~2.000, degrada antes
Tempo de migrationConstanteLinear no nº de tenants
Custo de conexão nova~2 ms~40 ms com 2.000 schemas
Exclusão de um tenantDELETE em cascata, minutosDROP SCHEMA, segundos
Risco de vazamento entre tenantsAlto sem RLS, nulo com RLSBaixo por construção
Restore de um único tenantExige extração seletivaRestore direto do schema
migrations/0001_init.sql
create table tenants (
  id          uuid primary key default gen_random_uuid(),
  slug        text unique not null,
  plan        text not null default 'free',
  created_at  timestamptz not null default now()
);

create table memberships (
  tenant_id uuid not null references tenants(id) on delete cascade,
  user_id   uuid not null references users(id) on delete cascade,
  role      text not null check (role in ('owner','admin','member')),
  primary key (tenant_id, user_id)
);

create table projects (
  id         uuid primary key default gen_random_uuid(),
  tenant_id  uuid not null references tenants(id) on delete cascade,
  name       text not null,
  created_at timestamptz not null default now()
);

-- índice sempre com tenant_id no prefixo
create index projects_tenant_created on projects (tenant_id, created_at desc);

alter table projects enable row level security;
create policy tenant_isolation on projects
  using (tenant_id = current_setting('app.tenant_id')::uuid);
RLS e pooler em modo transaction

SET app.tenant_id fora de uma transação vaza para a próxima requisição que reutilizar a mesma conexão do pooler. Use sempre set_config('app.tenant_id', $1, true) — o terceiro argumento true limita o escopo à transação corrente. Esse é o defeito de isolamento mais comum em SaaS multi-tenant e não aparece em testes de carga com um único tenant.

Storage

No estágio inicial, upload é um problema de capacidade antes de ser um problema de storage. Com concurrency: 40 e o teto de 8 instâncias da tabela acima, o plano comporta 320 requisições em voo; se cada arquivo de 10 MB em rede móvel lenta prende um desses slots por 40 segundos, algumas centenas de clientes enviando fotos ao mesmo tempo derrubam o produto inteiro — inclusive as telas que nada têm a ver com upload — sem que uma única query tenha ficado lenta. Por isso os bytes não atravessam o container: a aplicação valida a permissão e devolve uma URL pré-assinada de 15 minutos, o cliente escreve direto no a44-store, e a requisição que a instância realmente atende dura poucos milissegundos. O mecanismo da assinatura, com multipart e content-length-range, está detalhado em Developers.

A chave do objeto deve começar pelo identificador do tenant — t/<tenant_id>/u/<uuid>.<ext>. Isso viabiliza política de acesso por prefixo, cálculo de consumo por cliente com um único ListObjectsV2, e exclusão completa de um tenant sem varrer o bucket. Buckets planos tornam as três operações inviáveis.

src/uploads.ts
import { store } from '@anthares44/sdk';

export async function createUploadUrl(tenantId: string, ext: string) {
  return store.presign({
    bucket: 'tenant-uploads',
    key: `t/${tenantId}/u/${crypto.randomUUID()}.${ext}`,
    method: 'PUT',
    expiresIn: 900,              // 15 min; máximo aceito: 3600
    maxBytes: 25 * 1024 * 1024,  // acima disso o storage responde 413
    contentType: `image/${ext}`, // fixado na assinatura, não negociável
  });
}
ParâmetroPadrãoDescrição
expiresIn900Segundos de validade. Acima de 3600 a assinatura é rejeitada na geração.
maxBytes26214400Aplicado pelo storage, não pelo cliente. Excedente retorna 413 e não consome cota.
lifecycle_days365Expiração automática do objeto. 0 desativa e o objeto persiste até DELETE explícito.
storage_classstandardcold reduz o GB-mês em 62% e adiciona 3 a 5 s de latência na primeira leitura.
Confirme o upload no banco

Uma URL pré-assinada emitida não garante que o arquivo chegou. Grave a linha com status = 'pending' antes e promova para 'ready' no webhook store.object.created. Sem isso, o produto acumula referências órfãs que só aparecem como imagens quebradas semanas depois.

Autenticação

O a44-auth emite JWTs de acesso com validade curta (padrão de 15 minutos) e refresh tokens rotativos de 30 dias. O claim que interessa ao multi-tenancy é tid: ele carrega o tenant ativo da sessão, não a lista de tenants do usuário. Colocar a lista inteira no token parece conveniente, mas cria um problema de revogação — remover alguém de uma organização não teria efeito até o token expirar, deixando uma janela de até 15 minutos de acesso indevido.

A troca de tenant é uma operação explícita contra POST /auth/switch, que valida a membership no banco e emite um token novo. Isso mantém a autorização sempre a uma consulta de distância do estado real. O custo é uma leitura indexada por troca, tipicamente 0,4 ms — irrelevante frente ao risco de autorização obsoleta.

exemplo de access token
{
  "iss": "https://auth.a44.dev/acme-saas",
  "sub": "usr_8f21c0a4",
  "tid": "tnt_39b7e1",
  "role": "admin",
  "scope": "projects:read projects:write",
  "sid": "ses_7c1a",
  "exp": 1784500000,
  "iat": 1784499100
}
src/middleware/tenant.ts
export async function withTenant(req, res, next) {
  // verify valida contra a JWKS, cacheada por 10 minutos
  const claims = await auth.verify(req.headers.authorization);
  if (!claims.tid) return res.status(403).json({ error: 'no_active_tenant' });

  await db.transaction(async (tx) => {
    await tx.query("select set_config('app.tenant_id', $1, true)", [claims.tid]);
    req.db = tx;   // toda query da requisição usa esta transação
    await next();
  });
}
SSO pode esperar

SAML e SCIM aparecem em ciclos de venda com empresas de mais de 200 funcionários. Implementá-los antes do primeiro pedido consome de 3 a 4 semanas de uma equipe pequena. O a44-auth permite ativar SAML por tenant depois, sem migração de usuários, desde que o identificador canônico seja o sub interno e não o e-mail.

APIs

A API pública deve nascer versionada em /v1 mesmo que só exista um consumidor — o custo é um segmento na rota, e a alternativa é descobrir aos seis meses que três clientes dependem de um formato de resposta que você precisa mudar. Erros seguem um envelope único com error.code estável em snake_case, error.message legível e request_id. O código é contrato; a mensagem, não.

Paginação por cursor desde o início, e aqui a razão é contratual antes de ser técnica. Trocar o esquema de paginação depois que dois integradores já escreveram ?offset= nos scripts deles obriga a servir os dois formatos durante um ciclo de depreciação inteiro — semanas de manutenção que uma decisão de cinco minutos no dia um teria evitado, e que caem sobre a equipe justamente quando ela está ocupada com os primeiros clientes. O cursor opaco, base64 de (created_at, id), ainda impede que um item apareça duas vezes quando algo é inserido entre duas páginas: numa listagem de cobranças isso vira ticket de suporte, não curiosidade. O custo de varredura do OFFSET alto está medido em Developers.

curl
curl "https://api.acme-saas.a44.dev/v1/projects?limit=50" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 2c9e-4471" \
  -H "X-A44-Tenant: tnt_39b7e1"
200 OK
{
  "data": [
    { "id": "prj_1a", "name": "Landing", "created_at": "2026-07-02T11:20:00Z" }
  ],
  "page": {
    "next_cursor": "eyJjIjoiMjAyNi0wNy0wMlQxMToyMDowMFoiLCJpIjoicHJqXzFhIn0",
    "has_more": true
  },
  "request_id": "req_5f2b81c0"
}
Statuserror.codeQuando ocorreO cliente deve
401token_expiredAccess token venceuUsar o refresh token e repetir uma vez
403tenant_mismatchX-A44-Tenant difere do claim tidNão repetir; corrigir o cabeçalho
409idempotency_conflictMesma chave, corpo diferenteGerar chave nova
429rate_limitedCota do tenant excedidaAguardar Retry-After, backoff exponencial
503db_unavailableFailover do Postgres em cursoRepetir após 2 s, até 3 tentativas

Cursor opaco

Base64 de (created_at, id). Custo constante independente da página.

Idempotency-Key

Obrigatória em POST. Resposta memorizada por 24 h.

Rate limit

60 req/s por tenant no Starter, com burst de 120 por 10 s.

Bots com IA

O a44-bots executa agentes que consultam dados do projeto por meio de ferramentas declaradas, não por acesso direto ao banco. Cada ferramenta é uma função registrada com schema de entrada e um escopo; o agente nunca recebe uma conexão SQL. Isso resolve o problema de isolamento por construção: um agente executando no contexto de um tenant recebe um handle cujas queries já carregam o app.tenant_id correspondente, e nenhum prompt consegue contornar a política de RLS.

O caso de uso com melhor relação entre esforço e valor costuma ser triagem de suporte: classificar a mensagem, anexar contexto da conta e responder o que se repete. O ganho é mensurável — 30 a 45% de resolução automática em tickets de primeiro nível — e o modo de falha é contido, porque o pior resultado é escalar para um humano.

bots/triage.py
from anthares44 import bots

@bots.tool(scope="tickets:read", timeout_s=8)
def buscar_conta(ctx, email: str) -> dict:
    """Retorna plano e uso da conta. ctx.db já está limitado ao tenant."""
    row = ctx.db.one(
        "select plan, seats, storage_gb from tenants where owner_email = $1",
        email,
    )
    return row or {}

agente = bots.Agent(
    name="triagem-suporte",
    tools=[buscar_conta],
    max_steps=6,              # acima disso, encerra e escala para humano
    budget_tokens=12_000,     # por conversa; excedente retorna budget_exceeded
    on_low_confidence="escalate",
)
Custo variável dentro de um produto de preço fixo

Uma conversa de triagem consome entre 4.000 e 12.000 tokens. Sem budget_tokens por tenant, um único cliente em loop de conversa pode gerar em um dia um custo maior que a mensalidade dele. Defina o teto por plano e registre o consumo na mesma tabela que mede as demais cotas.

Deploy

O deploy acontece em duas fases: migrations e rollout. As migrations rodam como um job separado, contra a URL direta do banco, antes de qualquer container novo receber tráfego. Se a migration falha, o rollout é abortado e a versão antiga continua servindo — nenhum estado intermediário chega aos usuários. Se a migration passa mas o rollout falha no health check, a plataforma reverte os containers automaticamente, mas não reverte a migration. Essa assimetria é a razão de toda migration precisar ser compatível com a versão anterior do código.

Na prática isso significa expansão e contração em releases separados. Adicionar uma coluna NOT NULL em uma tabela existente quebra o código antigo durante os 90 segundos do rollout; adicionar a coluna como nullable, fazer o backfill, e só no release seguinte aplicar a constraint, não quebra nada. O custo é um release extra; a alternativa é um incidente por deploy de schema.

  1. Expandir

    Adicione a coluna nullable com default. Deploy 1: o código antigo ignora a coluna, o novo já escreve nela.

  2. Backfill

    Preencha em lotes de 5.000 linhas com pausa de 200 ms, para manter o replication lag abaixo de 1 s.

  3. Contrair

    Deploy 2 aplica SET NOT NULL e remove o caminho de leitura da coluna antiga.

  4. Limpar

    Deploy 3 dropa a coluna obsoleta, no mínimo 24 h depois — janela para rollback sem perda de dados.

.github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test
      - run: npx a44 migrate --env prod --lock-timeout 5s
      - run: npx a44 deploy --env prod --strategy canary --canary-percent 10 --bake 5m
        env:
          A44_TOKEN: ${{ secrets.A44_TOKEN }}
lock-timeout evita a fila em cascata

--lock-timeout 5s faz a migration desistir se não conseguir o lock em 5 segundos. Sem isso, um ALTER TABLE esperando por uma transação longa bloqueia todas as queries subsequentes na mesma tabela, e o banco fica indisponível por minutos com CPU em 3%.

Escalabilidade

O erro mais caro em infraestrutura não é escalar tarde, é escalar cedo: sharding, cache distribuído e microsserviços adicionam complexidade permanente para resolver um problema que ainda não existe. A regra aqui é escalar quando um sinal cruza o limiar por três dias consecutivos, não por intuição sobre crescimento futuro.

Os quatro sinais que importam estão na tabela abaixo. Nenhum deles é "número de usuários" — usuários registrados não consomem recursos, requisições e bytes consomem. O primeiro degrau quase sempre é o banco, e a resposta correta ao primeiro degrau é uma réplica de leitura, não um plano maior: o padrão de carga de um SaaS transacional é dominado por leitura, e a réplica custa cerca de 60% de uma instância primária equivalente.

SinalLimiar de açãoAçãoGanho esperado
CPU do Postgres, média 5 minacima de 65% por 3 diasAdicionar réplica de leituraAbsorve ~70% das queries
Conexões ativas no pooleracima de 80% de default_pool_sizeElevar o pool e revisar queries longasElimina fila de aquisição
p95 do gatewayacima de 800 ms por 3 diasElevar max_instances para 16Linear até saturar o banco
Atraso da fila de jobsacima de 120 s de idade do job mais antigoSeparar o role worker em serviço próprioDesacopla de picos web
Estágio 0 — pré-lançamento1 instância, pg-dev-2, sem réplica. Até 300 usuários ativos por mês. Custo aproximado: R$ 180/mês.
Estágio 1 — primeiros clientes2 a 8 instâncias, pg-std-4, PITR ativo. Até 5.000 MAU e 15 req/s. Custo aproximado: R$ 950/mês.
Estágio 2 — traçãoRéplica de leitura, workers separados, cache de sessão. Até 50.000 MAU e 120 req/s. Custo aproximado: R$ 4.200/mês.
Estágio 3 — particionamentoTabelas de eventos particionadas por mês, tenants grandes em instância dedicada. Acima disso, ver Enterprise.
O que quebra no estágio 3

Réplicas de leitura resolvem CPU, não escrita. Quando o volume de INSERT passa de cerca de 3.000 linhas por segundo sustentadas, o gargalo migra para o WAL e nenhuma réplica ajuda. A partir daí as opções são particionar por tempo, mover a tabela de maior escrita para armazenamento append-only, ou separar o tenant dominante — nessa ordem de custo crescente.

Monitoramento

Com equipe pequena, o critério para criar um alerta é: existe ação humana clara ao recebê-lo? Alerta de CPU alta sem ação associada gera fadiga e, em três semanas, ninguém olha o canal. Comece com quatro alertas pagináveis — 5xx acima de 1% por 5 min, p95 acima de 2 s por 10 min, atraso de fila acima de 300 s e falha de backup — e deixe o resto em dashboard.

O a44-obs ingere logs estruturados em JSON pelo stdout do container. Campos com nomes reservados (ts, level, msg, trace_id, tenant_id) viram dimensões indexadas; o resto entra como payload não indexado, pesquisável mas mais lento. Incluir tenant_id em toda linha é o que permite responder "esse cliente está com problema?" sem uma investigação de meia hora.

obs/alerts.yaml
alerts:
  - name: erro-5xx-elevado
    query: rate(http_responses{status=~"5.."}) / rate(http_responses) > 0.01
    for: 5m
    severity: page
    channels: [slack-oncall, sms]

  - name: fila-atrasada
    query: max(job_queue_oldest_seconds) > 300
    for: 2m
    severity: page

  - name: cota-storage
    query: sum(store_bytes) / 5.4e11 > 0.85
    for: 1h
    severity: ticket        # não acorda ninguém
consulta de logs
a44 logs --env prod --since 2h \
  --filter 'level=="error" and tenant_id=="tnt_39b7e1"' \
  --fields ts,msg,trace_id,route --limit 200

Métricas

Resolução de 15 s, retenção de 90 dias. Cardinalidade máxima de 50.000 séries por projeto.

Logs

30 dias, 50 GB/mês inclusos. Acima do teto, linhas INFO passam a ser amostradas a 10%.

Traces

Amostragem de 10%, com captura forçada de 100% em requisições que terminam em erro.

Segurança

Três controles cobrem a maior parte do risco real de um SaaS inicial e nenhum exige equipe de segurança: RLS em todas as tabelas com tenant_id, segredos fora do repositório, e nenhuma porta aberta na internet além do gateway. Eles eliminam, na ordem, o defeito mais grave do modelo multi-tenant, o vazamento por repositório clonado e a varredura automatizada contra o Postgres.

Segredos ficam no a44 secrets, com envelope encryption em repouso e injeção como variáveis de ambiente no start do container — não aparecem na imagem, no docker history nem nos logs de build. a44 secrets rotate mantém o valor antigo válido por uma janela de até 24 h, para instâncias em execução terminarem seu ciclo.

terminal
a44 secrets set STRIPE_KEY --env prod --stdin < key.txt
a44 secrets rotate STRIPE_KEY --env prod --grace 6h
a44 net policy set --deny-public --allow-egress api.pagamentos.exemplo:443
a44 audit --since 30d --actor '*' --action 'secrets.*'
ControleOnde se aplicaO que acontece se ausente
Row Level SecurityToda tabela com tenant_idUm WHERE esquecido expõe dados de outro cliente
VPN privadaAcesso administrativo ao bancoPostgres exposto sofre tentativa de login em minutos
Rate limit por tenanta44-gateUm cliente com bug em retry consome a capacidade de todos
Audit logAlterações de segredo, papel e cobrançaIncidente sem linha do tempo reconstruível
Egress allowlistSaída de rede do containerDependência comprometida exfiltra dados sem obstáculo
O dono da tabela ignora RLS

Políticas de RLS não se aplicam ao papel proprietário da tabela nem a superusuários. Se a aplicação conecta com o mesmo papel que rodou as migrations, a RLS está ativa e completamente inerte. Crie um papel app_rw separado, com GRANT apenas de DML, e verifique com a44 pg check-rls, que lista tabelas sem política e conexões com papel privilegiado.

Backups

Todo projeto tem snapshot diário com retenção de 7 dias no plano inicial e point-in-time recovery de granularidade 1 segundo nos planos pg-std e acima, com janela de 14 dias. O parâmetro ignorado com mais frequência é o RTO real: restaurar 100 GB leva de 12 a 20 minutos, e é isso que define o pior caso de indisponibilidade — não o RPO, praticamente zero com PITR.

O backup do banco não cobre o a44-store. Objetos são replicados em três zonas, o que protege contra falha de hardware mas não contra um DELETE equivocado do seu próprio código. Ative versionamento no bucket que guarda dados de cliente: o custo adicional é o armazenamento das versões anteriores dentro do período de retenção, tipicamente 8 a 15% a mais, e é o único mecanismo que reverte uma exclusão em massa acidental.

terminal
# restaura para um instante exato, em instância nova (não sobrescreve prod)
a44 pg restore --env prod --to '2026-07-18T14:32:10Z' --into pg-restore-tmp

# extrai somente um tenant do restore e reinjeta
a44 pg dump --from pg-restore-tmp --where "tenant_id='tnt_39b7e1'" --out tenant.sql
a44 pg apply --env prod --file tenant.sql --on-conflict skip
Ensaie o restore com quem não o escreveu

Em equipe de duas a cinco pessoas, o risco raramente é o backup falhar: é a única pessoa que sabe restaurar estar de férias no dia. Uma vez por trimestre, rode a44 pg restore --into pg-drill --to now-1h, execute os smoke tests contra a instância restaurada e destrua-a — sempre com alguém diferente conduzindo. São cerca de 40 minutos, e o produto do exercício não é o restore em si: é o runbook que a segunda pessoa escreve enquanto tropeça nos passos.

Integrações

Cobrança é a primeira integração de qualquer SaaS e a que mais gera inconsistência de dados. Trate o provedor de pagamento como fonte de verdade do estado da assinatura e o seu banco como cache: nunca conceda acesso com base na resposta de checkout no navegador, apenas em webhook verificado. O usuário fecha a aba, a rede cai, e o webhook chega assim mesmo.

Webhooks de entrada exigem três propriedades: verificação de assinatura, idempotência e resposta rápida. O a44-gate aceita um endpoint de webhook com verificação declarativa e devolve 202 imediatamente, gravando o evento na fila. Se o handler processa de forma síncrona e demora mais de 5 segundos, o provedor considera falha e reenvia — e sem idempotência você cobra ou provisiona duas vezes.

src/webhooks/billing.ts
export const handler = gate.webhook({
  path: '/hooks/billing',
  verify: { scheme: 'hmac-sha256', header: 'X-Sig', secret: env.BILLING_SECRET },
  tolerance_s: 300,          // rejeita replays com timestamp antigo
  ack: 'immediate',          // responde 202 e enfileira
}, async (evt, ctx) => {
  const seen = await ctx.db.query(
    'insert into webhook_events(id) values ($1) on conflict do nothing returning id',
    [evt.id],
  );
  if (seen.rowCount === 0) return;   // duplicata: já processado

  if (evt.type === 'subscription.updated') {
    await ctx.db.query(
      'update tenants set plan = $2 where id = $1',
      [evt.data.tenant_id, evt.data.plan],
    );
  }
});
ProvedorPOST assinado
a44-gateverifica HMAC · 202
Filadedup por id do evento
Handleratualiza plano
IntegraçãoDireçãoEstratégia de falha
PagamentosEntrada (webhook)Reprocessável por até 72 h; estado sempre reconciliável por GET na assinatura
E-mail transacionalSaída (fila)5 tentativas com backoff 1s/4s/16s/64s/256s; depois vai para dead-letter
Webhooks do clienteSaídaDesativa o endpoint após 20 falhas consecutivas e notifica o tenant por e-mail
Analytics de produtoSaída (batch)Perda tolerável; nunca no caminho síncrono da requisição

Boas práticas

Plano gratuito é uma decisão de infraestrutura antes de ser uma decisão comercial. Um free tier sem limites aplicados no código transfere a conta de armazenamento e computação de usuários que nunca converterão para o seu custo fixo. Aplique as cotas na mesma camada que serve a requisição, não em um relatório mensal: quando o limite é apenas contratual, ele já foi excedido quando alguém percebe.

A segunda prática é evitar dívida de infraestrutura, que se acumula diferente da dívida de código: esta é local e refatorável, aquela é global e costuma exigir downtime para pagar. Os quatro itens que mais cobram juros: falta de tenant_id em tabela criada às pressas, migrations não idempotentes, segredos em arquivo versionado, e ausência de índice na coluna do ORDER BY de listagem paginada.

RecursoFreeStarterGrowth
Projetos por tenant110Ilimitado
Requisições de API10k/mês500k/mês10M/mês
Storage1 GB50 GB500 GB
Retenção de dados30 dias1 anoConfigurável
Tokens de IA50k/mês2M/mês25M/mês
Ao excederBloqueio de escritaCobrança por excedenteCobrança por excedente
src/quota.sql
-- contador incrementado na própria transação da escrita
create table usage_counters (
  tenant_id uuid   not null,
  period    date   not null,
  metric    text   not null,
  value     bigint not null default 0,
  primary key (tenant_id, period, metric)
);

create or replace function bump_usage(p_tenant uuid, p_metric text, p_by bigint)
returns bigint language sql as $$
  insert into usage_counters (tenant_id, period, metric, value)
  values (p_tenant, date_trunc('month', now())::date, p_metric, p_by)
  on conflict (tenant_id, period, metric)
    do update set value = usage_counters.value + excluded.value
  returning value;
$$;

Cota na escrita

Incremente o contador na mesma transação do INSERT. Contagem assíncrona sempre subestima.

Degradar, não derrubar

Ao exceder, bloqueie escrita e mantenha leitura e exportação. Perder acesso aos próprios dados gera churn imediato.

Avisar em 80%

Notifique em 80% e 95% da cota. Um bloqueio sem aviso prévio é lido como falha do produto.

Contas gratuitas ociosas

Contas free sem sessão há 60 dias costumam representar de 40 a 70% do volume armazenado. Uma política de arquivamento — mover objetos para storage_class: cold aos 60 dias e excluir aos 180, com dois avisos por e-mail — reduz o custo de storage do free tier em cerca de metade sem afetar quem usa o produto.


Por fim, mantenha por escrito a lista de decisões reversíveis. Um arquivo DECISIONS.md com data, decisão, alternativa descartada e o sinal que indicaria revisitá-la custa 5 minutos por decisão e evita que a equipe redebata o mesmo assunto a cada trimestre com metade do contexto perdido. Recomendado

FAQ

As perguntas abaixo cobrem as dúvidas que aparecem com mais frequência nas primeiras oito semanas de um projeto novo. Elas complementam, e não repetem, o conteúdo das seções anteriores — cada resposta trata de um caso de borda ou de uma decisão que só surge quando o produto já está em uso.

Quando a resposta depender do seu tráfego real, meça por uma semana antes de agir. A maior parte dos ajustes deste guia é reversível em minutos; os que não são estão marcados como irreversíveis no texto de cada seção.

Posso começar por schema e migrar para linha depois?

Tecnicamente sim, mas é a migração mais cara do catálogo: exige unificar chaves primárias que podem colidir entre schemas, reescrever todas as chaves estrangeiras e mover os dados com downtime ou dupla escrita. Estime de duas a quatro semanas com 500 tenants. O caminho inverso — linha para schema, para um cliente específico — é bem mais simples e é o que se usa no estágio 3.

Quando faz sentido separar os workers do processo web?

Quando o atraso da fila passa de 120 segundos por três dias, ou quando um pico de jobs já causou degradação visível no p95 de requisições HTTP. Antes disso, a separação apenas adiciona um serviço a monitorar. A mudança em si é barata: mesma imagem, A44_ROLE=worker, escala independente.

Preciso de cache antes do primeiro cliente?

Não. Postgres com índices corretos responde listagens típicas em 3 a 12 ms, e um cache introduz invalidação — que é onde nasce a classe de bug "o cliente vê dado antigo". Considere cache quando uma query específica aparecer no topo do pg_stat_statements com mais de 15% do tempo total e não puder ser melhorada por índice.

O que acontece se a migration falhar no meio?

Cada migration roda em transação própria, então uma falha reverte por completo — exceto comandos que o Postgres não permite em transação, como CREATE INDEX CONCURRENTLY. Esses ficam em arquivos marcados com -- a44:no-transaction e precisam ser idempotentes, usando IF NOT EXISTS. O rollout não inicia se qualquer migration falhar.

Como estimo o custo antes de ter tráfego?

Use a44 cost simulate --mau 5000 --rps 15 --storage-gb 40. O simulador aplica as curvas de cada serviço e devolve a faixa mensal com intervalo de ±20%. O maior erro de estimativa costuma ser log: uma aplicação com nível debug ligado em produção facilmente ingere 200 GB/mês e custa mais que o banco.

Dá para rodar em mais de uma região?

A aplicação sim, com a44-run replicado. O banco não: o primário fica em uma região e a latência de escrita cross-region fica entre 80 e 180 ms. Multi-região com escrita local exige particionar dados por região, o que só compensa com exigência de residência de dados — assunto tratado em Enterprise.

Como excluo todos os dados de um cliente?

a44 tenant purge --id tnt_39b7e1 --confirm executa DELETE em cascata a partir de tenants, remove o prefixo t/<tenant_id>/ do bucket, invalida sessões e registra o evento no audit log. Objetos versionados só somem após a retenção de versões expirar; use --purge-versions para forçar.

O plano gratuito conta contra a minha cota de infraestrutura?

Sim. Requisições, storage e tokens de contas free consomem os mesmos recursos do projeto e aparecem na sua fatura. É por isso que os limites do free tier precisam ser aplicados em código: eles são, na prática, o teto do seu custo de aquisição.