Voltar para Artigos
DevOps11 min de leitura

Arquitetura Cloud: Upload Direto para S3 e Distribuição com CloudFront

Upload direto do browser para S3 com Presigned URLs (sem passar pela API), bucket privado com Origin Access Control, invalidação de cache no CloudFront, validação de tipo MIME e IAM least-privilege.

12 de agosto de 2026

Quando sua API Node.js recebe um upload de 10MB de imagem, ela bloqueia um worker para fazer I/O de rede enquanto recebe o arquivo, processa e envia para o storage. Com 100 uploads simultâneos, isso pode paralisar completamente a API. O padrão correto é Presigned URL: a API gera uma URL temporária assinada pelo IAM, e o browser faz o upload direto para o S3 — a API nunca vê o arquivo.

Além disso, servir arquivos estáticos diretamente do S3 em us-east-1 para usuários no Brasil adiciona ~200ms de latência por arquivo. O CloudFront resolve isso com edge caching em São Paulo — a segunda requisição do mesmo arquivo é servida em milissegundos da edge location local.

Arquitetura com Presigned URL

  1. Frontend solicita à API: POST /uploads/presigned-url com o nome e tipo do arquivo
  2. API valida o tipo MIME, gera uma Presigned URL com expiração curta (ex: 5 minutos) e retorna para o frontend
  3. Frontend faz o upload diretamente para o S3 usando a URL assinada — a API não processa o arquivo
  4. Após o upload, o frontend notifica a API com a chave do objeto no S3
  5. A API armazena a referência do arquivo no banco de dados
bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Essa arquitetura elimina um gargalo de I/O da API: o processo Node.js nunca precisa ler o arquivo de uma requisição multipart, nem redirecionar os bytes para o S3. A Presigned URL tem uma assinatura criptográfica gerada pelo IAM que valida o tipo de conteúdo, o bucket de destino e a chave do objeto — qualquer tentativa de desviar para outro bucket ou caminho é rejeitada pelo S3.

S3StorageProvider.ts
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomBytes } from 'crypto';
import path from 'path';

const ALLOWED_MIME_TYPES = new Set([
  'image/jpeg',
  'image/png',
  'image/webp',
  'image/avif',
  'application/pdf',
]);

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

export class S3StorageProvider {
  private client: S3Client;
  private bucket: string;
  private cdnUrl: string;

  constructor() {
    this.client = new S3Client({
      region: process.env.AWS_REGION as string,
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
      },
    });
    this.bucket = process.env.AWS_S3_BUCKET as string;
    this.cdnUrl = process.env.CLOUDFRONT_URL as string; // Ex: https://d111.cloudfront.net
  }

  /**
   * Gera uma Presigned URL para upload direto do browser para o S3.
   * A API nunca processa o arquivo — zero I/O na API.
   */
  async generatePresignedUploadUrl({
    filename,
    mimeType,
    folder = 'uploads',
  }: {
    filename: string;
    mimeType: string;
    folder?: string;
  }): Promise<{ uploadUrl: string; key: string; publicUrl: string }> {
    // Valida o tipo MIME antes de gerar a URL
    if (!ALLOWED_MIME_TYPES.has(mimeType)) {
      throw new Error(`Tipo de arquivo não permitido: ${mimeType}`);
    }

    // Gera uma chave aleatória para evitar colisões e enumeration
    const uniqueId = randomBytes(16).toString('hex');
    const extension = path.extname(filename).toLowerCase();
    const key = `${folder}/${uniqueId}${extension}`;

    const command = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      ContentType: mimeType,
      // Metadados opcionais para rastreabilidade
      Metadata: {
        originalFilename: encodeURIComponent(filename),
      },
      // Restringe o tipo de conteúdo que pode ser enviado
      // (o browser deve enviar o header Content-Type correto)
    });

    // URL expira em 5 minutos — tempo suficiente para o upload
    const uploadUrl = await getSignedUrl(this.client, command, { expiresIn: 300 });

    // URL pública via CloudFront (nunca exponha a URL do S3 diretamente)
    const publicUrl = `${this.cdnUrl}/${key}`;

    return { uploadUrl, key, publicUrl };
  }

  /**
   * Gera uma Presigned URL para download privado (arquivos não-públicos).
   */
  async generatePresignedDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
    const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
    return getSignedUrl(this.client, command, { expiresIn });
  }

  async delete(key: string): Promise<void> {
    await this.client.send(
      new DeleteObjectCommand({ Bucket: this.bucket, Key: key })
    );
  }
}

O Controller valida o payload com Zod (incluindo o fileSize para rejeitar requisições de URLs para arquivos maiores que o permitido antes mesmo de gerar a URL) e organiza os uploads em pastas por usuário. A pasta users/${req.user.id} garante isolamento entre usuários e facilita auditoria de quem fez upload de quais arquivos.

UploadsController.ts
import type { Request, Response } from 'express';
import { z } from 'zod';
import { S3StorageProvider } from '../infra/providers/storage/S3StorageProvider';

const presignedUrlSchema = z.object({
  filename: z.string().min(1).max(255),
  mimeType: z.string(),
  fileSize: z.number().int().positive().max(10 * 1024 * 1024), // Máximo 10MB
});

export class UploadsController {
  private storage = new S3StorageProvider();

  async requestPresignedUrl(req: Request, res: Response): Promise<void> {
    const body = presignedUrlSchema.parse(req.body);

    const result = await this.storage.generatePresignedUploadUrl({
      filename: body.filename,
      mimeType: body.mimeType,
      folder: `users/${req.user.id}`,
    });

    res.json({
      // URL para o frontend fazer PUT diretamente no S3
      uploadUrl: result.uploadUrl,
      // Chave para referenciar no banco após o upload
      key: result.key,
      // URL pública via CloudFront para exibir após o upload
      publicUrl: result.publicUrl,
    });
  }
}

Upload no Frontend

Frontend: upload com Presigned URL
async function uploadFile(file: File): Promise<string> {
  // 1. Solicita a Presigned URL à API
  const { uploadUrl, key, publicUrl } = await fetch('/api/uploads/presigned-url', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
    body: JSON.stringify({
      filename: file.name,
      mimeType: file.type,
      fileSize: file.size,
    }),
  }).then((r) => r.json());

  // 2. Faz o upload diretamente para o S3 — a API não vê este request
  const uploadResponse = await fetch(uploadUrl, {
    method: 'PUT',
    body: file,
    headers: { 'Content-Type': file.type },
  });

  if (!uploadResponse.ok) {
    throw new Error('Falha no upload para o S3');
  }

  // 3. Notifica a API da chave do arquivo
  await fetch('/api/users/avatar', {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
    body: JSON.stringify({ avatarKey: key }),
  });

  return publicUrl; // URL do CloudFront para exibir imediatamente
}

Configuração do Bucket e CloudFront

A configuração IAM Least Privilege é um detalhe crítico frequentemente ignorado: a role do servidor que gera as Presigned URLs precisa apenas de s3:PutObject e s3:DeleteObject. Não precisa de s3:GetObject — quem lê os arquivos é o CloudFront via Origin Access Control. O bucket completamente privado garante que nenhum arquivo seja acessível diretamente pelo URL do S3, forçando todo o tráfego a passar pelo CloudFront onde você pode configurar caching, segurança e distribuição geográfica.

  1. Bucket privado: desative 'Block all public access' para ON — nenhum acesso público direto ao S3
  2. Origin Access Control (OAC): crie um OAC e configure o bucket policy para permitir apenas o CloudFront acessar os objetos
  3. CloudFront: crie uma distribuição com o S3 como origin, configure o OAC, habilite HTTP/2 e HTTP/3, e configure Cache-Control headers
  4. IAM Least Privilege: o usuário IAM da API precisa apenas de s3:PutObject e s3:DeleteObject — não de s3:GetObject ou acesso global
Bucket Policy para CloudFront OAC
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::seu-bucket/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
        }
      }
    }
  ]
}

Quando você deleta ou substitui um arquivo no S3, o CloudFront ainda serve a versão antiga do cache por horas ou dias. Para forçar a atualização, crie uma invalidação no CloudFront: aws cloudfront create-invalidation --distribution-id E1234 --paths '/uploads/usuario123/*'. Isso tem custo adicional — use nomes de arquivos com hash aleatório para evitar a necessidade de invalidações.

Conclusão

Presigned URLs + S3 + CloudFront é a tríade que resolve os três problemas de upload/storage em produção: desempenho da API (uploads não passam pela API), escalabilidade (S3 suporta qualquer volume), e velocidade de entrega (CloudFront serve da edge location mais próxima do usuário). O bucket privado + OAC garante que nenhum arquivo seja acessado diretamente sem passar pelo CloudFront.