AWS S3 em Produção: Buckets, Segurança e Padrões de Upload para Apps Full-Stack cover image

AWS S3 em Produção: Buckets, Segurança e Padrões de Upload para Apps Full-Stack

O Amazon Simple Storage Service (S3) é um daqueles blocos fundamentais da AWS que você passa a usar o tempo todo assim que coloca qualquer coisa em produção de verdade. Avatares de usuário, PDFs de fatura, imagens de produto, thumbnails de vídeo, exportações CSV, backups — se é um arquivo e precisa sobreviver a um restart do servidor, S3 costuma ser a resposta.

O que torna o S3 aparentemente simples é a superfície da API: criar um bucket, enviar um objeto, baixá-lo depois. O que o torna pronto para produção é tudo em volta disso — permissões IAM, criptografia, regras de lifecycle, CORS e a decisão de se o seu servidor vai manipular cada byte ou se o navegador envia direto.

Este post percorre os padrões que uso em projetos Node.js, Laravel e Nuxt: configuração prática, padrões de segurança e fluxos de upload que escalam sem transformar sua API em proxy de arquivos.

O Que o S3 Realmente É (e o Que Não É)

O S3 é armazenamento de objetos, não um sistema de arquivos e não um banco de dados. Você guarda objetos (arquivos) dentro de buckets. Cada objeto tem uma chave (nome parecido com caminho), metadados e tags opcionais. Não existem pastas no sentido tradicional — barras nas chaves são convenção de nomenclatura, e o console as renderiza como diretórios.

Propriedades que importam para apps web:

  • Durabilidade: o S3 foi projetado para 99,999999999% (11 noves) de durabilidade entre Zonas de Disponibilidade.
  • Disponibilidade: o storage Standard mira 99,99% de disponibilidade. Para a maioria dos assets voltados ao usuário, isso é mais do que suficiente.
  • Consistência eventual: sobrescritas e exclusões propagam rápido, mas não assuma leitura imediata após escrita em fluxos distribuídos sem testar.
  • Namespace plano por bucket: nomes de bucket são globalmente únicos. Planeje a nomenclatura cedo — não dá para renomear um bucket no lugar.

O S3 não é CDN por si só. Para assets públicos servidos a navegadores no mundo todo, combine com o CloudFront. Para assets privados, evite ACLs públicas e use URLs pré-assinadas ou URLs assinadas do CloudFront.

Configuração do Bucket: Nome, Região e Block Public Access

Comece com um nome de bucket deliberado, ligado ao ambiente: myapp-assets-prod, myapp-uploads-staging. Inclua o ambiente no nome para que um deploy mal configurado não escreva no lugar errado.

Escolha uma região próxima dos usuários e do seu compute. Tráfego entre regiões adiciona latência e custo. Se sua API Laravel roda em sa-east-1 (São Paulo), mantenha o bucket lá, a menos que uma estratégia global de CDN justifique storage de origem nos EUA.

Ative o Block Public Access na conta e no bucket por padrão. O padrão antigo de bucket público para sites estáticos ainda existe, mas para uploads de aplicação é uma armadilha. Apps modernos devem tratar todo objeto como privado até ser compartilhado por um mecanismo controlado.

Ligue a criptografia padrão (SSE-S3 ou SSE-KMS). SSE-S3 atende a maioria dos casos. SSE-KMS oferece trilhas de auditoria e controle mais fino de chaves quando compliance exige.

IAM: Privilégio Mínimo Sem Travar o Deploy

Sua aplicação nunca deve usar credenciais da conta root. Crie um usuário IAM ou, de preferência, uma role IAM associada à instância EC2, task ECS ou função Lambda.

Uma policy mínima para um serviço de upload pode permitir:

  • s3:PutObject e s3:PutObjectAcl (se necessário) em arn:aws:s3:::myapp-uploads-prod/uploads/*
  • s3:GetObject no mesmo prefixo se o servidor gera downloads
  • s3:DeleteObject apenas se o app trata exclusão iniciada pelo usuário

Restrinja recursos a um prefixo, não ao bucket inteiro. Seu worker de resize de imagem não deveria poder apagar backups do banco em backups/.

Em desenvolvimento local, use profiles nomeados ou variáveis de ambiente (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION). No CI, use OIDC para assumir uma role — evite access keys de longa duração em secrets do GitHub Actions quando puder.

Padrões de Upload: Pelo Servidor vs Direto no S3

Existem dois padrões dominantes, e escolher o errado é como apps ficam lentas e caras.

Padrão 1: Upload Mediado pelo Servidor

O cliente envia o arquivo para sua API. A API faz stream para o S3 com o AWS SDK. É direto e concentra validação de tipo, tamanho e ownership antes do storage.

Em Node.js com AWS SDK v3:

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: process.env.AWS_REGION });

await s3.send(new PutObjectCommand({
  Bucket: 'myapp-uploads-prod',
  Key: `uploads/${userId}/${fileId}.webp`,
  Body: fileBuffer,
  ContentType: 'image/webp',
}));

No Laravel, a config filesystems com driver s3 mantém isso limpo:

Storage::disk('s3')->put(
  "uploads/{$userId}/{$fileId}.webp",
  $request->file('avatar')->get(),
  'public' // prefira private + URL pré-assinada
);

Use esse padrão quando arquivos são pequenos, uploads são raros ou você precisa de processamento pesado no servidor (antivírus, transformação de imagem) antes de ir ao S3.

Padrão 2: URLs Pré-Assinadas (Upload Direto)

Para arquivos maiores — vídeo, imports em massa, imagens em alta resolução — passar cada byte pela API desperdiça banda e ocupa workers. Em vez disso:

  • O cliente pede um slot de upload à API.
  • A API valida permissões e devolve uma URL PUT pré-assinada (ou policy POST para multipart).
  • O cliente envia direto ao S3.
  • O cliente avisa a API, que grava metadados no banco.

É o padrão que uso por padrão em apps Nuxt com API separada. O frontend nunca vê credenciais AWS. A URL expira em minutos, é limitada a uma chave e pode impor content type.

No servidor (exemplo Node.js):

import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const command = new PutObjectCommand({
  Bucket: 'myapp-uploads-prod',
  Key: `uploads/${userId}/${uuid}.pdf`,
  ContentType: 'application/pdf',
});

const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });

No cliente, um fetch simples com PUT e o corpo do arquivo basta. Acompanhe progresso com XMLHttpRequest ou bibliotecas se precisar de barra de progresso.

CORS para Upload no Navegador

Se seu app Nuxt faz upload pelo browser, o S3 precisa permitir sua origem. Configure CORS no bucket:

[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Não use "*" em origens em produção quando há credenciais ou URLs pré-assinadas. Seja explícito.

Servindo Arquivos de Volta ao Usuário

Objetos privados exigem URLs GET pré-assinadas geradas sob demanda. Cache a URL na resposta da API com expiração alinhada ao TTL da presign, não maior.

Assets públicos de marketing (logos, imagens da landing) podem ficar em bucket ou prefixo separado, servidos via CloudFront com domínio customizado e HTTPS. Mantenha conteúdo gerado por usuário fora desse caminho público.

Ao exibir imagens em Vue/Nuxt, guarde a chave S3 no banco, não a URL completa. Chaves são estáveis; URLs dependem de domínio CloudFront, expiração de presign ou ambiente. Resolva URLs na leitura.

Lifecycle e Controle de Custo

O S3 é barato até deixar de ser. Uploads sem limite acumulam silenciosamente.

  • Mova arquivos pouco acessados para S3 Intelligent-Tiering ou Glacier após N dias.
  • Expire partes de upload temporário e multipart incompleto.
  • Apague objetos quando o app excluir o registro pai — o S3 não faz isso sozinho, a menos que configure expiração por prefixo.

Ative S3 Storage Lens ou pelo menos alertas de billing. Um bug que loga cada resposta de API no S3 encarece rápido.

Erros Comuns em Code Review

  • Bucket público para upload de usuário. Trate todo upload como hostil. Escaneie, valide MIME type e sirva por URLs controladas.
  • Dados sensíveis sem criptografia. Ative criptografia padrão e restrinja chaves KMS.
  • Usar nome do bucket como chave primária. Guarde uma chave UUID; deixe o usuário renomear o nome de exibição nos metadados.
  • Pular content-type no upload. Navegadores dependem disso. Tipos errados quebram preview e podem abrir vetores XSS com políticas de serving ruins.
  • Hardcodar credenciais no bundle client do Nuxt. Nunca. Só URLs pré-assinadas.

Desenvolvimento Local e Testes

Use LocalStack ou MinIO para emular S3 localmente, ou aponte o ambiente dev a um bucket dedicado com lifecycle agressivo de exclusão. O AWS SDK aceita endpoint customizado:

const s3 = new S3Client({
  region: 'us-east-1',
  endpoint: 'http://localhost:4566',
  forcePathStyle: true,
});

Escreva testes de integração que enviam, buscam e apagam um objeto real no emulador. Testes unitários sozinhos não pegam IAM ou CORS mal configurados.

Fechando o Fluxo

O S3 funciona em produção quando você o trata como parte da arquitetura da aplicação, não como disco burro. Defina limites claros de bucket por ambiente, mantenha objetos privados por padrão, escolha upload mediado ou presign conforme tamanho e validação, e guarde chaves — não URLs — no banco.

Seja em monolito Laravel, microserviço Node.js ou frontend Nuxt falando com endpoint de presign, valem os mesmos princípios: IAM com privilégio mínimo, criptografia ligada, acesso público desligado e lifecycle alinhado ao tempo de vida real dos dados.

Com essa base, dá para adicionar CloudFront, Lambdas de otimização de imagem ou processamento orientado a eventos depois, sem reescrever a camada de storage.