Upload que some no deploy: guarde o arquivo no blob, não na pasta do site
Mova os uploads do disco da aplicação para um armazenamento de objetos, com chave gerada por você, metadados no banco e download que confere o dono.
Terça, 10h: o cliente reclama que os contratos em PDF enviados ontem sumiram. Na segunda à noite houve deploy. A pasta wwwroot/uploads morava dentro do container, e o container novo nasceu vazio. A resposta curta: arquivo de usuário não pode morar no disco da aplicação. Envie para um armazenamento de objetos (Azure Blob Storage, Amazon S3 ou equivalente) com um nome que você gera, guarde a chave e o dono no banco e entregue o download só depois de conferir quem está pedindo.
Isso resolve três problemas de uma vez: o deploy deixa de apagar dados, duas instâncias da aplicação passam a enxergar os mesmos arquivos e o disco do servidor para de encher com PDF de cliente.
Por que a pasta local perde o arquivo
A pasta do site faz parte do ciclo de vida da aplicação. Em container, cada deploy cria um sistema de arquivos novo a partir da imagem, e o que foi gravado em tempo de execução vai embora junto com o container antigo. Em serviços gerenciados de aplicação, o disco local costuma ser temporário pelo mesmo motivo. Mesmo numa VM tradicional, um script de deploy que apaga e recria a pasta publicada leva os uploads junto.
Escalar piora. Com duas instâncias atrás de um balanceador, o arquivo enviado para a instância A não existe na instância B. Metade dos downloads dá 404, dependendo de para onde o balanceador mandou a requisição. Ninguém consegue reproduzir com consistência, e o bug parece aleatório.
O armazenamento de objetos separa as duas coisas. A aplicação é descartável e o arquivo é durável. O serviço cuida de redundância, e você define retenção e backup como política, não como um zip esquecido na área de trabalho.
O contrato entre aplicação, banco e blob
Antes do código, combine quem guarda o quê. O blob guarda os bytes. O banco guarda a chave do blob, o nome original, o tamanho, o tipo, o dono e a data. A aplicação é a única porta de entrada e de saída.
- Receba o arquivo e confira tamanho e tipo pelo conteúdo, não pela extensão.
- Gere uma chave sua, como
2026/10/3f9c...pdf. Nunca use o nome que a pessoa enviou como caminho. - Envie para um container privado, sem acesso público.
- Grave no banco a chave, o nome original e o dono.
- No download, busque pelo id e pelo dono juntos. Se não achar, responda 404.
- Transmita o conteúdo pela aplicação ou gere um link assinado que vence em poucos minutos.
Exemplo com ASP.NET Core e Azure Blob Storage
O exemplo usa o pacote Azure.Storage.Blobs. Em desenvolvimento, o emulador Azurite aceita a connection string UseDevelopmentStorage=true, então você testa sem conta na nuvem. Em produção, a connection string vem de variável de ambiente ou cofre de segredos, nunca do repositório.
// Program.cs
builder.Services.AddSingleton(_ =>
new BlobServiceClient(builder.Configuration.GetConnectionString("Blob")));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var blobs = scope.ServiceProvider.GetRequiredService<BlobServiceClient>();
await blobs.GetBlobContainerClient("documentos").CreateIfNotExistsAsync();
}
O CreateIfNotExistsAsync sem parâmetro de acesso cria o container privado. É o que você quer: ninguém baixa nada direto pela URL do blob.
[ApiController]
[Route("documentos")]
[Authorize]
public class DocumentosController : ControllerBase
{
private const long Limite = 10_000_000;
private readonly BlobContainerClient _container;
private readonly AppDb _db;
public DocumentosController(BlobServiceClient blobs, AppDb db)
{
_container = blobs.GetBlobContainerClient("documentos");
_db = db;
}
[HttpPost]
[RequestSizeLimit(Limite)]
public async Task<IActionResult> Enviar(IFormFile arquivo, CancellationToken ct)
{
if (arquivo is null || arquivo.Length == 0 || arquivo.Length > Limite)
return BadRequest("Envie um PDF de até 10 MB.");
await using var entrada = arquivo.OpenReadStream();
var cabecalho = new byte[5];
if (await entrada.ReadAsync(cabecalho, ct) < 5 ||
Encoding.ASCII.GetString(cabecalho) != "%PDF-")
return BadRequest("O arquivo não é um PDF.");
entrada.Position = 0;
var chave = $"{DateTime.UtcNow:yyyy/MM}/{Guid.NewGuid():N}.pdf";
await _container.GetBlobClient(chave).UploadAsync(entrada, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/pdf" }
}, ct);
var doc = new Documento
{
Chave = chave,
NomeOriginal = Path.GetFileName(arquivo.FileName),
DonoId = User.FindFirstValue(ClaimTypes.NameIdentifier)!,
Tamanho = arquivo.Length,
CriadoEm = DateTime.UtcNow
};
_db.Documentos.Add(doc);
await _db.SaveChangesAsync(ct);
return Ok(new { doc.Id });
}
[HttpGet("{id:int}")]
public async Task<IActionResult> Baixar(int id, CancellationToken ct)
{
var dono = User.FindFirstValue(ClaimTypes.NameIdentifier);
var doc = await _db.Documentos
.FirstOrDefaultAsync(d => d.Id == id && d.DonoId == dono, ct);
if (doc is null) return NotFound();
var resposta = await _container.GetBlobClient(doc.Chave)
.DownloadStreamingAsync(cancellationToken: ct);
return File(resposta.Value.Content, "application/pdf", doc.NomeOriginal);
}
}
Repare em três detalhes. A checagem do tipo lê os primeiros bytes do arquivo (todo PDF começa com %PDF-), como explica MIME type: a extensão não diz o que tem dentro. O nome original só aparece no download, como sugestão de nome para o navegador. E a consulta de download filtra por id e dono na mesma condição, então pedir o id de outra pessoa dá 404, igual a um id inexistente.
Transmitir pela aplicação ou entregar link assinado
Transmitir pela aplicação, como no exemplo, é simples e mantém todo controle no seu código, mas cada download passa pelo seu servidor. Para arquivos grandes ou muito acessados, o caminho é um link assinado de curta duração: SAS no Azure, URL pré-assinada no S3. A aplicação confere o dono e devolve um endereço que vale por poucos minutos. Link público permanente para documento de cliente é o erro que blob storage não fica público porque o upload funcionou detalha.
Migrar os arquivos que já estão na pasta
Se o sistema já tem uploads no disco, faça a migração em duas etapas. Primeiro, todo upload novo vai para o blob, e o download procura no blob e, se não achar, na pasta antiga. Depois, um script copia os arquivos antigos, grava a chave no banco e confere a contagem. Só quando a contagem bater você remove a leitura da pasta e apaga os arquivos locais.
No caso dos contratos, a migração levou uma tarde: 3.200 arquivos, 4,1 GB, um script rodando por lote de 200 com log de cada chave copiada. O deploy seguinte não apagou nada, porque não havia mais nada para apagar.
Erros comuns
- Usar o nome original como caminho: espaço, acento e
../viram problema de segurança e de URL, e dois arquivos com o mesmo nome se sobrescrevem. - Container com acesso público "só para facilitar": qualquer pessoa que adivinhar ou receber a URL baixa o documento, sem login.
- Gravar no blob e na pasta "por segurança": em pouco tempo ninguém sabe qual cópia é a verdadeira, e o disco enche do mesmo jeito.
- Conferir só a extensão: um executável renomeado para
.pdfpassa e fica servido pelo seu domínio. - Connection string do storage no appsettings versionado: quem tem acesso ao repositório tem acesso a todos os arquivos dos clientes.
- Apagar o registro no banco e esquecer o blob: o arquivo fica órfão, pago todo mês, com dado pessoal que você disse ter excluído.
Checklist
- Nenhum upload é gravado dentro da pasta publicada da aplicação.
- O container de documentos é privado.
- A chave do blob é gerada pela aplicação, sem o nome original.
- O banco guarda chave, nome original, dono, tamanho e data.
- O download filtra por id e dono, e devolve 404 para os dois casos de falha.
- Links diretos, quando existem, vencem em minutos.
- A exclusão remove o registro e o blob.
- Um teste sobe um arquivo, faz deploy e baixa o mesmo arquivo depois.
Suba um arquivo no seu sistema, faça um deploy e tente baixá-lo de novo. Ele ainda está lá, e quem mais, além do dono, conseguiria montar a URL dele?
Referência: https://medium.com/volosoft/file-upload-download-with-blob-storage-system-in-asp-net-core-abp-framework-e