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.
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.
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.
a44-gate: TLS, roteamento por hostname, rate limit por tenant, cache de resposta para GETs idempotentes.a44-run: containers stateless, autoscaling por concorrência, workers de fila no mesmo binário.a44-auth: emissão de JWT com claim de tenant, sessões, chaves de API por organização.a44-pg com RLS ativa; a44-store para binários; ambos na rede privada do projeto.a44-obs: retenção de 30 dias, alertas por regra, trace amostrado a 10%.| Serviço | Unidade de cobrança | Teto do plano inicial | Sintoma ao estourar |
|---|---|---|---|
a44-pg | vCPU-hora + GB armazenados | 2 vCPU / 100 GB / 120 conexões | too many connections no pool |
a44-run | GB-segundo de container ativo | 8 instâncias simultâneas | Fila de admissão, p99 sobe em degrau |
a44-store | GB-mês + requisições | 500 GB / 5M req por mês | HTTP 429 no upload |
a44-gate | Requisições processadas | 2.000 req/s por hostname | HTTP 429 com Retry-After |
a44-obs | GB de log ingerido | 50 GB/mês, retenção 30 dias | Amostragem 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.
Instalar e autenticar
a44 loginabre o navegador e grava um token de 90 dias em~/.a44/credentials. Em CI, useA44_TOKENcom um token de serviço de escopo restrito.Criar o projeto
a44 init --template saas-tsgera o esqueleto com migrations, RLS já configurada e um health check em/healthz.Aplicar a infraestrutura
a44 applyreconcilia o YAML com o estado real e imprime um plano antes de executar. Sem--yes, pede confirmação para qualquer operação destrutiva.Primeiro deploy
a44 deploy --env prodconstrói a imagem, roda as migrations e faz rollout gradual. Duração média de 90 a 140 segundos.Verificar
a44 status --env proddeve mostrarhealthyem todos os componentes e latência p50 abaixo de 80 ms no endpoint de saúde.
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
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
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.
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.
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);
});
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ável | Padrão | Descrição |
|---|---|---|
PORT | 8080 | Injetada pelo runtime. Ignorá-la faz o health check falhar e o rollout reverter. |
A44_DATABASE_URL | — | Aponta para o pooler. A URL direta está em A44_DATABASE_URL_DIRECT e só deve ser usada por migrations. |
A44_ROLE | web | web serve HTTP; worker consome a fila. Mesma imagem, comando diferente. |
A44_DEPLOY_ID | gerado | Identificador do release, propagado para logs e traces. Use-o para correlacionar regressões. |
A44_SHUTDOWN_GRACE | 25 | Segundos 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ério | Por linha (RLS) | Por schema |
|---|---|---|
| Tenants suportados | ~500.000 | ~2.000, degrada antes |
| Tempo de migration | Constante | Linear no nº de tenants |
| Custo de conexão nova | ~2 ms | ~40 ms com 2.000 schemas |
| Exclusão de um tenant | DELETE em cascata, minutos | DROP SCHEMA, segundos |
| Risco de vazamento entre tenants | Alto sem RLS, nulo com RLS | Baixo por construção |
| Restore de um único tenant | Exige extração seletiva | Restore direto do schema |
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);
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.
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âmetro | Padrão | Descrição |
|---|---|---|
expiresIn | 900 | Segundos de validade. Acima de 3600 a assinatura é rejeitada na geração. |
maxBytes | 26214400 | Aplicado pelo storage, não pelo cliente. Excedente retorna 413 e não consome cota. |
lifecycle_days | 365 | Expiração automática do objeto. 0 desativa e o objeto persiste até DELETE explícito. |
storage_class | standard | cold reduz o GB-mês em 62% e adiciona 3 a 5 s de latência na primeira leitura. |
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.
{
"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
}
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();
});
}
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 "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"
{
"data": [
{ "id": "prj_1a", "name": "Landing", "created_at": "2026-07-02T11:20:00Z" }
],
"page": {
"next_cursor": "eyJjIjoiMjAyNi0wNy0wMlQxMToyMDowMFoiLCJpIjoicHJqXzFhIn0",
"has_more": true
},
"request_id": "req_5f2b81c0"
}
| Status | error.code | Quando ocorre | O cliente deve |
|---|---|---|---|
401 | token_expired | Access token venceu | Usar o refresh token e repetir uma vez |
403 | tenant_mismatch | X-A44-Tenant difere do claim tid | Não repetir; corrigir o cabeçalho |
409 | idempotency_conflict | Mesma chave, corpo diferente | Gerar chave nova |
429 | rate_limited | Cota do tenant excedida | Aguardar Retry-After, backoff exponencial |
503 | db_unavailable | Failover do Postgres em curso | Repetir 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.
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",
)
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.
Expandir
Adicione a coluna nullable com default. Deploy 1: o código antigo ignora a coluna, o novo já escreve nela.
Backfill
Preencha em lotes de 5.000 linhas com pausa de 200 ms, para manter o replication lag abaixo de 1 s.
Contrair
Deploy 2 aplica
SET NOT NULLe remove o caminho de leitura da coluna antiga.Limpar
Deploy 3 dropa a coluna obsoleta, no mínimo 24 h depois — janela para rollback sem perda de dados.
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 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.
| Sinal | Limiar de ação | Ação | Ganho esperado |
|---|---|---|---|
| CPU do Postgres, média 5 min | acima de 65% por 3 dias | Adicionar réplica de leitura | Absorve ~70% das queries |
| Conexões ativas no pooler | acima de 80% de default_pool_size | Elevar o pool e revisar queries longas | Elimina fila de aquisição |
| p95 do gateway | acima de 800 ms por 3 dias | Elevar max_instances para 16 | Linear até saturar o banco |
| Atraso da fila de jobs | acima de 120 s de idade do job mais antigo | Separar o role worker em serviço próprio | Desacopla de picos web |
pg-dev-2, sem réplica. Até 300 usuários ativos por mês. Custo aproximado: R$ 180/mês.pg-std-4, PITR ativo. Até 5.000 MAU e 15 req/s. Custo aproximado: R$ 950/mês.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.
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
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.
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.*'
| Controle | Onde se aplica | O que acontece se ausente |
|---|---|---|
| Row Level Security | Toda tabela com tenant_id | Um WHERE esquecido expõe dados de outro cliente |
| VPN privada | Acesso administrativo ao banco | Postgres exposto sofre tentativa de login em minutos |
| Rate limit por tenant | a44-gate | Um cliente com bug em retry consome a capacidade de todos |
| Audit log | Alterações de segredo, papel e cobrança | Incidente sem linha do tempo reconstruível |
| Egress allowlist | Saída de rede do container | Dependência comprometida exfiltra dados sem obstáculo |
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.
# 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
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.
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],
);
}
});
| Integração | Direção | Estratégia de falha |
|---|---|---|
| Pagamentos | Entrada (webhook) | Reprocessável por até 72 h; estado sempre reconciliável por GET na assinatura |
| E-mail transacional | Saída (fila) | 5 tentativas com backoff 1s/4s/16s/64s/256s; depois vai para dead-letter |
| Webhooks do cliente | Saída | Desativa o endpoint após 20 falhas consecutivas e notifica o tenant por e-mail |
| Analytics de produto | Saí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.
| Recurso | Free | Starter | Growth |
|---|---|---|---|
| Projetos por tenant | 1 | 10 | Ilimitado |
| Requisições de API | 10k/mês | 500k/mês | 10M/mês |
| Storage | 1 GB | 50 GB | 500 GB |
| Retenção de dados | 30 dias | 1 ano | Configurável |
| Tokens de IA | 50k/mês | 2M/mês | 25M/mês |
| Ao exceder | Bloqueio de escrita | Cobrança por excedente | Cobrança por excedente |
-- 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 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.