Object Storage

Store large files in S3 or Blob Storage, stream rather than buffer, and hand out short-lived URLs that bypass your service.

Syntaxawait blobClient.UploadAsync(stream, overwrite: true, cancellationToken);

Object storage is a service that keeps whole files under a key, such as 2026/report.pdf. AWS calls the holder of those keys a bucket; Azure calls it a container. It is not a filesystem. There are no real directories, a key with slashes in it only looks like a path, and you cannot edit the middle of an object: you replace it.

Stream, do not buffer

Buffering means reading the whole file into memory first. A 200 MB upload buffered by twenty concurrent requests is 4 GB of memory, and your pod is killed. Streaming means handing the request body straight to the storage client, which copies it along in chunks. Both SDKs accept a Stream, so HttpRequest.Body goes in directly.

Let the browser talk to storage

A pre-signed URL on AWS, or SAS URL on Azure, is a link with a signature and an expiry inside it. Whoever holds it may do exactly one thing, such as read this one object, until it expires. You generate the link and return it; the browser then uploads or downloads directly from storage. The bytes never pass through your service, which is cheaper and takes the slowest part of the request off your pods.

What trips people up

Set the content type when you upload. Storage keeps whatever you tell it, and an object stored without one is served as application/octet-stream, so the browser downloads your image instead of displaying it.

Example

Example · csharp
// dotnet add package Azure.Storage.Blobs
using Azure.Storage.Blobs;
using Azure.Storage.Sas;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// The connection string carries the account key, so it comes from
// configuration and never from source.
string storage = builder.Configuration.GetConnectionString("Storage")
    ?? throw new InvalidOperationException("ConnectionStrings:Storage is not set.");

builder.Services.AddSingleton(new BlobContainerClient(storage, "uploads"));

WebApplication app = builder.Build();

// The request body is copied straight through to the blob.
app.MapPut("/files/{name}", async (string name, HttpRequest request,
    BlobContainerClient container, CancellationToken ct) =>
{
    BlobClient blob = container.GetBlobClient(name);
    await blob.UploadAsync(request.Body, overwrite: true, ct);
    return Results.Created($"/files/{name}", null);
});

// A link the browser reads storage with directly, good for 15 minutes.
app.MapGet("/files/{name}/link", (string name, BlobContainerClient container) =>
{
    BlobClient blob = container.GetBlobClient(name);
    Uri link = blob.GenerateSasUri(BlobSasPermissions.Read,
        DateTimeOffset.UtcNow.AddMinutes(15));
    return Results.Ok(new { url = link.ToString() });
});

app.Run();

When to use it

  • Accept a 200 MB video upload without the request filling the pod's memory.
  • Give a customer a download link for a private invoice that stops working in ten minutes.
  • Move user avatars out of Postgres and into a bucket so the database stays small.

More examples

The same two jobs on S3

Upload and link generation with the AWS SDK. Note that Expires takes a DateTime and the SDK treats it as UTC; a pre-signed URL cannot outlive the credential that signed it, so one signed by a pod's role dies when that role's temporary credential does.

Example · csharp
// dotnet add package AWSSDK.S3
using Amazon.S3;
using Amazon.S3.Model;

IAmazonS3 s3 = new AmazonS3Client();

// A FileStream can be read in chunks, so nothing is held in memory.
await using FileStream file = File.OpenRead("report.pdf");

await s3.PutObjectAsync(new PutObjectRequest
{
    BucketName = "reports-eu",
    Key = "2026/report.pdf",
    InputStream = file,
    ContentType = "application/pdf"
});

// The signature is computed locally. This call does not reach S3.
string url = await s3.GetPreSignedURLAsync(new GetPreSignedUrlRequest
{
    BucketName = "reports-eu",
    Key = "2026/report.pdf",
    Verb = HttpVerb.GET,
    Expires = DateTime.UtcNow.AddMinutes(15)
});

Streaming a download back through the service

ResponseStream is an open network stream, not a buffer, so memory stays flat whatever the file size. Do not read it into a byte array first; that is exactly the buffering this lesson warns about.

Example · csharp
using Amazon.S3;
using Amazon.S3.Model;

// Use this shape when the file must stay private and you have to check
// permissions in code. Otherwise prefer a pre-signed URL.
app.MapGet("/files/{name}", async (string name, IAmazonS3 s3, CancellationToken ct) =>
{
    GetObjectResponse response = await s3.GetObjectAsync("reports-eu", name, ct);

    // Results.Stream copies the stream to the client and disposes it.
    return Results.Stream(response.ResponseStream, response.Headers.ContentType);
});

A write link, so uploads never touch your pods

The browser does a PUT to uploadUrl with the file as the body, then tells your API the key it used. Give the link the shortest expiry the upload can finish in, and scope it to one key: Create and Write on a whole container is a hole.

Example · csharp
using Azure.Storage.Blobs;
using Azure.Storage.Sas;

app.MapPost("/uploads", (BlobContainerClient container) =>
{
    // Your service picks the key, so a caller cannot overwrite
    // somebody else's object.
    string key = $"incoming/{Ulid()}";
    BlobClient blob = container.GetBlobClient(key);

    Uri link = blob.GenerateSasUri(
        BlobSasPermissions.Create | BlobSasPermissions.Write,
        DateTimeOffset.UtcNow.AddMinutes(10));

    return Results.Ok(new { key, uploadUrl = link.ToString() });

    // A sortable key with no randomness in the printed result.
    static string Ulid() => "01JQ7Z0M9K4T2W8V6N3B5Q7XYZ";
});

Discussion

  • Be the first to comment on this lesson.