Python com S3, R2 e MinIO: uploads seguros de arquivos
Aprenda a fazer upload de arquivos com Python, boto3, S3 compatível, URLs assinadas, validação, organização de chaves e cuidados de produção.
Guardar arquivos parece simples até o projeto sair do notebook e virar produto. Um sistema que recebe currículo, nota fiscal, relatório, imagem, CSV ou PDF precisa decidir onde salvar, como validar, quem pode baixar, por quanto tempo o link funciona e como apagar ou reprocessar o arquivo depois. Em produção, deixar tudo em uma pasta local do servidor costuma quebrar no primeiro deploy com container, autoscaling ou máquina nova.
Por isso tantos projetos Python acabam usando storage de objetos: Amazon S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2 e outros serviços compatíveis com a API do S3. A ideia é simples: em vez de gravar arquivos no disco da aplicação, você envia objetos para um bucket e guarda no banco apenas os metadados necessários.
Este guia mostra um caminho prático com boto3, o cliente Python mais comum para S3 compatível. Ele complementa conteúdos como Python e AWS Lambda, FastAPI Background Tasks, Celery e Redis, HTTPX em Python para clientes resilientes e Python e Docker Compose com Postgres. O foco aqui é backend real: validação, URLs assinadas, nomes de arquivo previsíveis, segurança e testes locais.
Quando usar storage de objetos
Use S3, R2 ou MinIO quando o arquivo precisa sobreviver ao ciclo de vida da aplicação. Exemplos comuns:
- upload de currículo em plataforma de vagas;
- PDFs de relatório gerados por uma rotina Python;
- XML de nota fiscal processado por automação;
- imagens enviadas por usuários;
- datasets CSV ou Parquet consumidos por pipeline;
- anexos que serão analisados por uma fila Celery;
- arquivos temporários que precisam de expiração controlada.
A aplicação Python não deve depender de /tmp, volume local ou pasta do projeto para esse tipo de dado. Em ambiente containerizado, o disco local pode desaparecer no próximo deploy. Em ambiente com múltiplas réplicas, um arquivo salvo em uma instância não aparece nas outras. Storage de objetos resolve esse problema com uma API HTTP, permissões por bucket e boa integração com CDN, filas e eventos.
S3, R2 e MinIO: a mesma ideia, detalhes diferentes
S3 é o serviço original da AWS. R2 é a alternativa da Cloudflare com API compatível e foco em egress mais barato. MinIO é uma implementação que você pode rodar localmente ou em infraestrutura própria. Todos expõem o conceito de bucket, chave e objeto.
A diferença prática para Python aparece na configuração:
import boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url="https://SEU_ENDPOINT_COMPATIVEL",
aws_access_key_id="SUA_ACCESS_KEY",
aws_secret_access_key="SEU_SECRET_KEY",
region_name="auto",
config=Config(signature_version="s3v4"),
)
Na AWS, muitas vezes você não informa endpoint_url e usa uma região como sa-east-1 ou us-east-1. Em R2, costuma usar region_name="auto" e o endpoint da conta. Em MinIO local, o endpoint pode ser http://localhost:9000.
O código de upload, download e URL assinada fica parecido. Essa compatibilidade é útil para desenvolver com MinIO local e publicar em S3 ou R2 depois, desde que você teste os pontos que variam: região, política de bucket, tamanho máximo, CORS, URLs públicas e assinatura.
Configuração por variáveis de ambiente
Não coloque chaves no código, no README nem no repositório. Use variáveis de ambiente, secret manager ou a ferramenta de segredos do seu ambiente. Um módulo pequeno de configuração já evita muita bagunça:
from dataclasses import dataclass
from os import environ
@dataclass(frozen=True)
class StorageConfig:
endpoint_url: str | None
bucket: str
access_key: str
secret_key: str
region: str = "auto"
def carregar_config_storage() -> StorageConfig:
return StorageConfig(
endpoint_url=environ.get("S3_ENDPOINT_URL"),
bucket=environ["S3_BUCKET"],
access_key=environ["S3_ACCESS_KEY_ID"],
secret_key=environ["S3_SECRET_ACCESS_KEY"],
region=environ.get("S3_REGION", "auto"),
)
O uso de environ[...] nos campos obrigatórios é intencional: se faltar configuração, a aplicação falha cedo. Para projetos que já usam python-dotenv em desenvolvimento, você pode carregar um .env local sem transformar esse arquivo em fonte oficial de segredo.
Cliente reutilizável com boto3
Em vez de espalhar boto3.client(...) por controllers, workers e scripts, crie uma função de fábrica. Isso facilita trocar endpoint, testar e aplicar timeouts.
import boto3
from botocore.config import Config
def criar_cliente_s3(config: StorageConfig):
return boto3.client(
"s3",
endpoint_url=config.endpoint_url,
aws_access_key_id=config.access_key,
aws_secret_access_key=config.secret_key,
region_name=config.region,
config=Config(
signature_version="s3v4",
connect_timeout=3,
read_timeout=20,
retries={"max_attempts": 3, "mode": "standard"},
),
)
Timeout e retry importam porque upload é operação de rede. Sem limite, uma rota web pode ficar presa. Sem retry controlado, uma instabilidade pequena vira erro para o usuário. Com retry demais, você cria lentidão invisível. Comece conservador e ajuste com métricas.
Upload simples com Content-Type correto
O exemplo abaixo recebe bytes, nome lógico, tipo de conteúdo e salva o objeto. A função não usa o nome enviado pelo usuário como chave final, porque isso causa colisão, problemas com acentos, path traversal e URLs feias.
from datetime import UTC, datetime
from uuid import uuid4
def montar_chave(prefixo: str, nome_original: str) -> str:
hoje = datetime.now(UTC).strftime("%Y/%m/%d")
extensao = nome_original.rsplit(".", 1)[-1].lower() if "." in nome_original else "bin"
return f"{prefixo}/{hoje}/{uuid4().hex}.{extensao}"
def enviar_arquivo(
s3,
bucket: str,
conteudo: bytes,
nome_original: str,
content_type: str,
) -> str:
chave = montar_chave("uploads", nome_original)
s3.put_object(
Bucket=bucket,
Key=chave,
Body=conteudo,
ContentType=content_type,
Metadata={"nome-original": nome_original[:120]},
)
return chave
A chave usa data e UUID para evitar colisão e facilitar auditoria. O nome original vai para metadado truncado, não para o caminho principal. Em uma aplicação real, você também gravaria no banco: usuário, chave, tamanho, hash, content type, status, data de criação e finalidade.
Validação antes do upload
Nunca aceite qualquer arquivo sem validação. O storage não sabe se o arquivo faz sentido para o seu negócio. A aplicação precisa impor tamanho máximo, extensões permitidas e tipos aceitos.
TIPOS_PERMITIDOS = {
"application/pdf": ".pdf",
"text/csv": ".csv",
"image/png": ".png",
"image/jpeg": ".jpg",
}
def validar_upload(nome: str, content_type: str, tamanho: int) -> None:
if tamanho <= 0:
raise ValueError("Arquivo vazio")
if tamanho > 10 * 1024 * 1024:
raise ValueError("Arquivo maior que 10 MB")
extensao_esperada = TIPOS_PERMITIDOS.get(content_type)
if extensao_esperada is None:
raise ValueError("Tipo de arquivo não permitido")
if not nome.lower().endswith(extensao_esperada):
raise ValueError("Extensão não combina com o tipo informado")
Essa validação não substitui antivírus, análise de conteúdo ou regras específicas de documentos sensíveis. Ela é o primeiro filtro. Para casos críticos, salve o arquivo com status pendente, processe em uma fila e só libere depois de passar pelas verificações.
URL assinada para download privado
Deixar bucket público parece conveniente, mas quase sempre é uma má ideia para arquivos de usuário. Prefira bucket privado e gere URLs assinadas com expiração curta.
def gerar_url_download(s3, bucket: str, chave: str, segundos: int = 300) -> str:
return s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": chave},
ExpiresIn=segundos,
)
Com isso, sua aplicação decide quem pode baixar. O usuário autenticado pede o arquivo, você valida permissão no banco e só então gera a URL. A URL expira em poucos minutos. Para arquivos públicos, como imagem de artigo ou asset estático, você pode usar CDN e política pública, mas não misture esse modelo com documentos privados.
Upload direto pelo navegador
Em sistemas maiores, o navegador pode enviar o arquivo direto para o bucket usando URL assinada de PUT. Isso evita que sua API receba o corpo inteiro do arquivo. O fluxo fica assim:
- frontend pede intenção de upload para a API;
- API autentica o usuário, valida metadados e cria uma chave;
- API gera URL assinada de upload;
- navegador faz
PUTdireto no storage; - frontend avisa a API que terminou;
- worker valida o objeto e muda status para disponível.
O código para gerar URL de upload é parecido:
def gerar_url_upload(s3, bucket: str, chave: str, content_type: str) -> str:
return s3.generate_presigned_url(
"put_object",
Params={
"Bucket": bucket,
"Key": chave,
"ContentType": content_type,
},
ExpiresIn=600,
)
Esse padrão é ótimo para arquivos grandes, mas exige CORS bem configurado no bucket e uma tabela de controle no banco. Não confie apenas no frontend: depois do upload, confirme tamanho, tipo e existência do objeto pelo backend.
MinIO local com Docker Compose
MinIO ajuda a testar sem depender de uma conta cloud. Um docker-compose.yml mínimo:
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: dev_access_key
MINIO_ROOT_PASSWORD: dev_secret_key_123
volumes:
- minio_data:/data
volumes:
minio_data:
Depois, a aplicação usa:
export S3_ENDPOINT_URL=http://localhost:9000
export S3_BUCKET=uploads-dev
export S3_ACCESS_KEY_ID=dev_access_key
export S3_SECRET_ACCESS_KEY=dev_secret_key_123
export S3_REGION=us-east-1
Você ainda precisa criar o bucket, pelo console em http://localhost:9001 ou por script. Em CI, vale subir MinIO, criar bucket e rodar testes de integração rápidos para garantir que upload, metadados e URL assinada continuam funcionando.
Como testar sem bater no cloud real
Separe dois níveis de teste. Para unidade, use um cliente falso que registra chamadas esperadas. Para integração, use MinIO ou um ambiente de teste isolado.
class S3Fake:
def __init__(self):
self.objetos = {}
def put_object(self, Bucket, Key, Body, ContentType, Metadata):
self.objetos[(Bucket, Key)] = {
"body": Body,
"content_type": ContentType,
"metadata": Metadata,
}
def test_enviar_arquivo_salva_metadados():
s3 = S3Fake()
chave = enviar_arquivo(
s3,
bucket="teste",
conteudo=b"id,nome\n1,Ana\n",
nome_original="clientes.csv",
content_type="text/csv",
)
assert chave.startswith("uploads/")
objeto = s3.objetos[("teste", chave)]
assert objeto["content_type"] == "text/csv"
assert objeto["metadata"]["nome-original"] == "clientes.csv"
O fake não prova compatibilidade com S3, mas prova sua regra de negócio. O teste de integração prova o contrato com a API real. Não coloque credenciais de produção em CI.
Checklist de produção
Antes de publicar uma feature de upload, revise:
- bucket privado por padrão;
- credenciais com permissão mínima;
- tamanho máximo por tipo de arquivo;
- validação de extensão, Content-Type e, quando possível, assinatura do arquivo;
- chave sem dados pessoais e sem nome original cru;
- URL assinada com expiração curta;
- metadados persistidos no banco;
- logs sem segredo e sem conteúdo sensível;
- rotina de limpeza para uploads órfãos;
- testes com cliente fake e MinIO;
- monitoramento de erro, latência e volume.
Se o arquivo entra em fila, envie apenas a chave e o ID do registro. Não coloque o binário inteiro em Redis, RabbitMQ, Celery ou Kafka. Essa mesma regra aparece em sistemas de mensageria: mensagem transporta referência; storage guarda o payload pesado.
Conclusão
Python funciona muito bem com S3, R2 e MinIO quando você trata storage como parte da arquitetura, não como pasta remota. O código de upload é pequeno, mas as decisões ao redor são o que protegem o produto: validação, chave estável, bucket privado, URL assinada, metadados e testes.
Para um portfólio backend, esse tema também é excelente. Uma API FastAPI que aceita upload, salva em storage compatível com S3, dispara processamento em background e mostra status do arquivo demonstra maturidade maior do que um CRUD isolado. Se quiser comparar a mesma ideia em outro ecossistema, veja também conteúdos de backend em Golang Brasil e pense em como a responsabilidade de arquitetura é parecida mesmo quando a linguagem muda.
Equipe Python Brasil
Contribuidor do Python Brasil — Aprenda Python em Português