Salvar upload de usuário direto no disco do servidor funciona até o dia em que você precisa escalar horizontalmente, trocar de servidor ou fazer backup de verdade. Nesse momento, ou você migra para um provedor de object storage, ou recria a roda. O MinIO resolve isso sem te prender a nenhuma nuvem.

O que é o MinIO

MinIO é um servidor de armazenamento de objetos compatível com a API do S3 da AWS. Isso significa que qualquer SDK, CLI ou biblioteca feita para S3 funciona com MinIO sem alterações — você só troca o endpoint.

A diferença é que você roda o MinIO onde quiser: numa VPS de R$20/mês, num cluster on-premise ou num container ao lado da sua aplicação. Self-hosted, sem custo de egress, sem vendor lock-in.

Para uploads de usuário (avatares, documentos, anexos, imagens de produto), é a solução mais direta entre “salvar no disco e rezar” e “assinar contrato com a AWS”.

Subindo o MinIO com Docker

O jeito mais rápido de testar é via docker-compose:

services:
  minio:
    image: minio/minio:latest
    command: server /data --console-address ":9001"
    ports:
      - "9000:9000"   # API S3
      - "9001:9001"   # Console web
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: senha-forte-aqui
    volumes:
      - minio_data:/data

volumes:
  minio_data:
docker compose up -d

O console fica em http://localhost:9001. A API S3 (a que sua aplicação vai usar) fica em http://localhost:9000.

Em produção, troque MINIO_ROOT_PASSWORD por algo gerado, coloque atrás de um proxy com TLS e nunca exponha a porta 9000 direto pra internet sem autenticação adicional.

Criando o bucket

Com o cliente mc (MinIO Client), criar e configurar um bucket é uma questão de poucos comandos:

mc alias set local http://localhost:9000 admin senha-forte-aqui
mc mb local/uploads
mc anonymous set download local/uploads

O último comando torna os objetos do bucket uploads legíveis publicamente via URL direta — útil para avatares e imagens que vão aparecer no front-end. Para documentos sensíveis, não rode esse comando: deixe o bucket privado e sirva os arquivos só via URL assinada.

Os dois padrões de upload

Existem dois jeitos de subir um arquivo para o MinIO a partir da sua aplicação, e a escolha entre eles importa.

Upload via backend (proxy). O arquivo passa pelo seu servidor antes de chegar no MinIO. Simples de implementar, mas o arquivo trafega duas vezes pela rede e consome banda e memória do seu backend.

Upload via URL pré-assinada (presigned URL). O backend gera uma URL temporária com permissão de escrita, e o cliente (browser ou app) sobe o arquivo direto para o MinIO. O backend nunca vê o conteúdo do arquivo — só autoriza a operação.

Cliente                Backend                  MinIO
   │                       │                       │
   │  1. Pede permissão    │                       │
   │  para enviar arquivo  │                       │
   ├──────────────────────>│                       │
   │                       │  2. Gera presigned URL│
   │                       ├──────────────────────>│
   │                       │<──────────────────────┤
   │  3. Recebe a URL      │                       │
   │<──────────────────────┤                       │
   │                       │                       │
   │  4. Upload direto do arquivo (PUT)             │
   ├────────────────────────────────────────────────>│
   │                       │                       │
   │  5. Confirma sucesso e salva metadata no banco │
   ├──────────────────────>│                       │

Para a maioria dos casos — upload de imagem de perfil, anexo em formulário, documento de usuário — presigned URL é a escolha certa. Menos carga no backend, upload mais rápido, e o servidor só lida com metadados.

Integrando com Node.js

Usando o SDK oficial da AWS (compatível com MinIO):

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({
  endpoint: "http://localhost:9000",
  region: "us-east-1",
  credentials: {
    accessKeyId: process.env.MINIO_ACCESS_KEY,
    secretAccessKey: process.env.MINIO_SECRET_KEY,
  },
  forcePathStyle: true, // obrigatório para MinIO
});

async function gerarUrlDeUpload(nomeArquivo, tipoConteudo) {
  const command = new PutObjectCommand({
    Bucket: "uploads",
    Key: `avatares/${nomeArquivo}`,
    ContentType: tipoConteudo,
  });

  // URL válida por 5 minutos
  return getSignedUrl(s3, command, { expiresIn: 300 });
}

No front-end, o upload fica assim:

const url = await fetch("/api/upload-url?filename=foto.jpg&type=image/jpeg")
  .then((res) => res.json())
  .then((data) => data.url);

await fetch(url, {
  method: "PUT",
  headers: { "Content-Type": "image/jpeg" },
  body: arquivoSelecionado,
});

O detalhe que costuma travar quem migra de S3 puro: forcePathStyle: true. Sem isso, o SDK monta a URL no formato bucket.endpoint.com, que o MinIO não resolve por padrão.

Boas práticas que evitam dor de cabeça depois

Versionamento de bucket. Ative com mc version enable local/uploads se exclusão acidental é um risco real no seu caso de uso.

Lifecycle policy. Arquivos temporários (uploads abandonados no meio do fluxo, por exemplo) devem expirar automaticamente:

mc ilm add local/uploads --expiry-days 7 --prefix "temp/"

Buckets separados por ambiente. uploads-dev, uploads-staging, uploads-prod. Misturar ambiente de teste com produção no mesmo bucket é abrir margem para vazar dado de teste em produção — ou pior, o contrário.

Nunca confie no nome de arquivo enviado pelo cliente. Gere um nome próprio (UUID, hash, timestamp) no backend antes de montar a Key do objeto. Confiar no nome original é abrir a porta para path traversal e sobrescrita de arquivo.

Com isso rodando, trocar de MinIO self-hosted para S3 da AWS — ou o contrário — é mudança de três variáveis de ambiente. É essa portabilidade que faz valer a pena não acoplar o código direto a um provedor específico desde o primeiro dia.