ADR-0030 Armazenamento de ficheiros e objetos
Padrão da empresa para ficheiros — a API S3 sobre um store compatível com S3 (Ceph RADOS Gateway por predefinição), buckets privados e URLs pré-assinados para uploads e downloads a partir do browser.
- Decisão
- API S3 sobre Ceph ou outro store compatível com S3Guardar e proteger
- Estado
- Aceite
- Data
- Âmbito
- Predefinição da empresa para todas as novas apps
Ligações
Contexto
Seção intitulada “Contexto”As apps guardam ficheiros: uploads, exportações geradas, anexos. Precisam de um sítio que não seja nem uma linha no Postgres nem o disco do contentor.
- Os contentores são substituídos em cada deploy e as réplicas não partilham sistema de ficheiros (ADR-0003)
- A edge limita o corpo dos pedidos a 10 MB (ADR-0017), pelo que os uploads grandes não podem passar pela app
- Os deployments variam: Ceph na nossa infraestrutura, ou um store compatível com S3 que o cliente já opera
- Os ficheiros têm regras de acesso: quem pode ler um ficheiro é decidido pela app, pedido a pedido (ADR-0009)
O que está em aberto: a API de armazenamento, o cliente e como os browsers enviam e obtêm ficheiros.
Opções consideradas
Seção intitulada “Opções consideradas”1. Disco local ou um volume Docker
Seção intitulada “1. Disco local ou um volume Docker”Prós: nada a mais para operar. Contras: preso a uma máquina, não partilhado entre réplicas, e o backup fica por inventar.
2. Postgres (bytea ou large objects)
Seção intitulada “2. Postgres (bytea ou large objects)”Prós: transacional com o resto dos dados; um só backup. Contras: todos os bytes passam pela app e pelo pool de ligações; a base de dados e os seus backups crescem com o volume de ficheiros.
3. A API S3 sobre um store compatível com S3
Seção intitulada “3. A API S3 sobre um store compatível com S3”Prós
- Uma só API, seja qual for o store do deployment
- Os URLs pré-assinados deixam o browser enviar e descarregar diretamente do store, sem passar pela app nem pelo seu limite de corpo
- O RADOS Gateway do Ceph implementa o que precisamos: URLs pré-assinados, CORS por bucket, uploads multipart, regras de ciclo de vida
Contras
- Um store para operar — e o Ceph não é pequeno. Isso cabe ao deployment
- “Compatível com S3” não é idêntico: as predefinições de checksums e o endereçamento dos buckets variam entre stores
4. O SDK próprio de um fornecedor (Azure Blob, Google Cloud Storage)
Seção intitulada “4. O SDK próprio de um fornecedor (Azure Blob, Google Cloud Storage)”Prós: as funcionalidades de primeira linha desse fornecedor. Contras: um módulo de armazenamento por fornecedor; mudar um produto obriga a reescrevê-lo.
Decisão
Seção intitulada “Decisão”A API S3 é o padrão da empresa para ficheiros. O Ceph RADOS Gateway é o store predefinido na nossa infraestrutura; um deployment pode usar qualquer store compatível com S3.
Em concreto:
- Cliente: o
S3Clientbuilt-in do Bun (import { S3Client } from "bun", ADR-0035), construído a partir do endpoint, da região, das credenciais e do bucket declarados emserver/env.ts(ADR-0026). Nunca o cliente implícitoBun.s3, que lê por si as variáveisS3_*eAWS_*. (Alterado em 2026-11-13: este ADR escolheu primeiro o@aws-sdk/client-s3com o seu presigner; o cliente do Bun substitui ambos.) - Endereçamento path-style, a predefinição do cliente (
virtualHostedStyleficafalse), para que qualquer endpoint compatível com S3 funcione sem DNS wildcard - Um só módulo de armazenamento,
src/server/storage/, é o único código que importa oS3Client. O código das funcionalidades recebe-o como parâmetro (ADR-0027) e não usa nada que uma API S3 simples não tenha - Os buckets são privados. Sem ACLs nem políticas de leitura pública
- Uma tabela
filesno Postgres é a fonte de verdade (ADR-0008): chave, tamanho, content type, dono, estado. O store guarda bytes; todas as decisões de acesso leem a linha - Os uploads vão do browser para o store. Uma server function
POSTverifica a sessão, valida com Zod o tipo e o tamanho declarados, escolhe a chave e devolve um URLPUTpré-assinado de curta duração (presign(key, { method: "PUT", expiresIn, type })). É o servidor que escolhe as chaves ({dono}/{uuid}) — nunca o nome de ficheiro do cliente - Confirmar antes de confiar. Depois do upload, o servidor lê os metadados do objeto com
file(key).stat(), compara o tamanho e o tipo com o que foi declarado e apaga-o se não coincidirem. Só então a linha fica marcada como pronta - Os downloads são URLs
GETpré-assinados de curta duração, emitidos depois da mesma verificação de autorização. Os tipos não confiáveis são servidos comContent-Disposition: attachment(a opção de presigncontentDisposition) - Configuração do deployment: o endpoint do store é acessível aos browsers por TLS, com CORS a permitir a origem da app, e regras de ciclo de vida que abortam uploads multipart incompletos e expiram objetos não confirmados
- A durabilidade e o backup do store decidem-se por deployment, tal como os da base de dados (ADR-0032)
- Testes: os testes unitários passam um módulo de armazenamento falso; os de integração correm contra um contentor compatível com S3 em Testcontainers (ADR-0014)
Os fatores decisivos: uma só API em todos os deployments, uploads que nunca passam pela app nem pelo seu limite de corpo, e ficheiros que ficam atrás da autorização da própria app. Aceitamos operar um store e verificar o dialeto S3 de cada store contra as chamadas que usamos.
Consequências
Seção intitulada “Consequências”Mais fácil:
- As réplicas e os deploys nunca perdem um ficheiro; a app mantém-se sem estado
- Os uploads grandes funcionam sem subir o limite de corpo da edge
- Mudar entre o Ceph e outro store compatível com S3 é uma alteração de configuração
Mais difícil:
- Um upload tem dois passos e pode ficar a meio: as linhas e os objetos não confirmados precisam de limpeza
- O endpoint do store, o CORS e as regras de ciclo de vida são trabalho do deployment que tem de existir antes do primeiro upload. O cliente do Bun cobre as chamadas a objetos — presign, stat, read, write, delete, list — e não a administração de buckets, pelo que o deployment as define com as suas próprias ferramentas
- Um ficheiro e a sua linha podem discordar; ganha a linha, e os órfãos são limpos
Do que abdicamos: das funcionalidades específicas de cada store (notificações de eventos, object lock, CDNs do fornecedor) como padrão. Aceite — um produto que precise de uma escreve o seu próprio ADR.

