---
title: "Python com S3, R2 e MinIO: uploads seguros de arquivos"
url: "https://python.dev.br/blog/python-s3-r2-minio-upload-arquivos/"
markdown_url: "https://python.dev.br/blog/python-s3-r2-minio-upload-arquivos.MD"
description: "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."
date: "2026-06-15"
author: "Equipe Python Brasil"
---

# 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](/blog/python-e-aws-lambda/), [FastAPI Background Tasks, Celery e Redis](/blog/fastapi-background-tasks-celery-redis-2026/), [HTTPX em Python para clientes resilientes](/blog/httpx-timeouts-retries-python/) e [Python e Docker Compose com Postgres](/blog/python-docker-compose-postgres-ambiente-local/). 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:

```python
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:

```python
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](/blog/python-dotenv-env-vars-config/) 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.

```python
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.

```python
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.

```python
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.

```python

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:

1. frontend pede intenção de upload para a API;
2. API autentica o usuário, valida metadados e cria uma chave;
3. API gera URL assinada de upload;
4. navegador faz `PUT` direto no storage;
5. frontend avisa a API que terminou;
6. worker valida o objeto e muda status para disponível.

O código para gerar URL de upload é parecido:

```python

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:

```yaml
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:

```bash
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.

```python
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](https://golang.com.br/?utm_source=python.dev.br&utm_medium=referral&utm_campaign=python-s3-storage) e pense em como a responsabilidade de arquitetura é parecida mesmo quando a linguagem muda.
