Newsletter

Get the Thursday issue.

One focused C# tutorial. Free, and easy to leave.

  • 4,000+ engineers
  • 100+ tutorials
Getting Started with Amazon S3 in ASP.NET Core

Getting Started with Amazon S3 in ASP.NET Core

05 Nov 2026 · 7 min

Sponsor Newsletter

Most applications eventually need to store files. Profile pictures, invoices, documents and exports all have to live somewhere.

Saving them on the local disk works for a single server. As soon as you scale out or deploy to the cloud that approach falls apart.

You need durable storage that every instance can reach, without coupling your API to a specific machine.

That's where Amazon S3 comes in. In this post we'll wire up simple upload, download and delete operations in ASP.NET Core.

Amazon S3

Amazon S3 is object storage. You store files as objects inside a bucket, each identified by a key.

For .NET, AWS provides the official AWSSDK.S3 package. It gives you an IAmazonS3 client for the common operations we need.

Before writing any code, create an S3 bucket in the AWS console and an IAM user with access to that bucket. You will need the region, bucket name, access key and secret key.

Getting Started

Install the S3 SDK package:

shell
dotnet add package AWSSDK.S3

In the sample I also use Ardalis.Result so the storage service can return success, invalid and not found outcomes without throwing. If you want a deeper look at that approach, check out my blog on the Result Pattern.

Configuration

Keep bucket settings in configuration. A simple section in appsettings.json looks like this:

json
{
  "StorageBucket": {
    "Region": "your-region",
    "BucketName": "your-bucket-name",
    "AccessKey": "access-key-from-iam",
    "SecretKey": "secret-key-from-iam"
  }
}

Map that section to a strongly typed options class:

csharp
namespace WebApi.Models.Options;

public sealed class StorageBucketOptions
{
    public string BucketName { get; set; }

    public string Region { get; set; }

    public string AccessKey { get; set; }

    public string SecretKey { get; set; }
}

Then bind it with the Options Pattern:

csharp
builder.Services.Configure<StorageBucketOptions>(
    builder.Configuration.GetSection("StorageBucket"));

NOTE: Do not commit real credentials. Prefer user secrets or environment variables for local development and a secret store in production. For more on binding and validating settings, see my post on the Options Pattern.

Registering the S3 Client

Register IAmazonS3 as a singleton. The client is thread-safe and expensive to create, so one shared instance is the right default.

csharp
builder.Services.AddSingleton<IAmazonS3>(sp =>
{
    var options = sp.GetRequiredService<IOptions<StorageBucketOptions>>().Value;

    var awsCredentials = new BasicAWSCredentials(options.AccessKey, options.SecretKey);

    var regionEndpoint = RegionEndpoint.GetBySystemName(options.Region);

    return new AmazonS3Client(awsCredentials, regionEndpoint);
});

builder.Services.AddScoped<IStorageService, StorageService>();

We build credentials and a region from options, then hand them to AmazonS3Client. The storage service stays scoped so it can take a fresh IOptions snapshot per request if you ever swap the options lifetime.

Storage Service

Hide the SDK behind a small abstraction. Callers should not care about PutObjectRequest or bucket names.

csharp
public interface IStorageService
{
    Task<Result<Guid>> UploadFileAsync(IFormFile fileStream);
    Task<Result<FileResponse>> DownloadFileAsync(Guid key);
    Task<Result> DeleteFileAsync(Guid id);
}

The download response is a simple record with the stream, content type and original file name:

csharp
public sealed record FileResponse(
    Stream Stream,
    string ContentType,
    string FileName);

The implementation injects IAmazonS3 and bucket options:

csharp
public class StorageService : IStorageService
{
    private readonly IAmazonS3 _amazonS3Client;
    private readonly StorageBucketOptions _storageBucketOptions;

    public StorageService(
        IAmazonS3 amazonS3Client,
        IOptions<StorageBucketOptions> storageBucketOptions)
    {
        _amazonS3Client = amazonS3Client;
        _storageBucketOptions = storageBucketOptions.Value;
    }
}

Upload

Upload uses PutObjectAsync. We generate a Guid as the object key and store the original file name in S3 metadata.

csharp
public async Task<Result<Guid>> UploadFileAsync(IFormFile formFile)
{
    if (formFile.Length == 0)
    {
        return Result.Invalid();
    }

    await using var stream = formFile.OpenReadStream();

    var id = Guid.NewGuid();

    var request = new PutObjectRequest()
    {
        BucketName = _storageBucketOptions.BucketName,
        Key = $"file-path/{id}",
        InputStream = stream,
        ContentType = formFile.ContentType,
        Metadata =
        {
            ["file-name"] = formFile.FileName,
        }
    };

    await _amazonS3Client.PutObjectAsync(request);

    return Result.Success(id);
}

Empty files are rejected early. Everything else is streamed straight to S3 under a predictable key prefix.

Download

Download mirrors the upload key. If the object is missing, we map the S3 not found response to Result.NotFound.

csharp
public async Task<Result<FileResponse>> DownloadFileAsync(Guid id)
{
    try
    {
        var request = new GetObjectRequest
        {
            BucketName = _storageBucketOptions.BucketName,
            Key = $"file-path/{id}"
        };

        var response = await _amazonS3Client.GetObjectAsync(request);

        var result = new FileResponse(
            response.ResponseStream,
            response.Headers.ContentType,
            response.Metadata["file-name"]);

        return Result.Success(result);
    }
    catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
    {
        return Result.NotFound();
    }
}

The content type and original file name come back with the stream, so the API can return a proper file download.

Delete

Delete is the simplest of the three. Build a DeleteObjectRequest and call DeleteObjectAsync.

csharp
public async Task<Result> DeleteFileAsync(Guid id)
{
    var request = new DeleteObjectRequest
    {
        BucketName = _storageBucketOptions.BucketName,
        Key = $"file-path/{id}"
    };

    await _amazonS3Client.DeleteObjectAsync(request);

    return Result.Success();
}

S3 delete is idempotent for missing keys in typical setups, so this path stays straightforward.

Minimal API Endpoints

Expose the service through a Minimal API group under /files. Antiforgery is disabled here because file uploads from tools like Swagger or external clients usually do not send antiforgery tokens.

csharp
public static class StorageEndpoints
{
    public static void RegisterStorageEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("files")
            .DisableAntiforgery();

        group.MapPost("", async (IStorageService storageService, IFormFile file) =>
        {
            var result = await storageService.UploadFileAsync(file);

            return result.IsSuccess
                ? Results.Ok(result.Value)
                : Results.BadRequest();
        });

        group.MapGet("{id}", async (IStorageService storageService, Guid id) =>
        {
            var result = await storageService.DownloadFileAsync(id);

            return result.IsSuccess
                ? Results.File(result.Value.Stream, result.Value.ContentType, result.Value.FileName)
                : Results.NotFound();
        });

        group.MapDelete("{id}", async (IStorageService storageService, Guid id) =>
        {
            await storageService.DeleteFileAsync(id);

            return Results.NoContent();
        });
    }
}

Register the endpoints in Program.cs with app.RegisterStorageEndpoints(). Upload returns the Guid. Download streams the file. Delete returns 204.

If you want a cleaner way to organize endpoint groups like this, I covered that here: Organize Minimal APIs in ASP.NET Core.

Conclusion

With a few lines of setup you get cloud file storage that works across instances.

Bind bucket settings, register IAmazonS3, wrap upload download and delete behind a service and expose them through Minimal APIs. For many apps, that is already a solid starting point.

What's next to learn?

  • Configuration - Versioning, access settings, ACLs and CORS
  • Pricing - Understanding how S3 usage is billed
  • Presigned URLs - Letting clients upload or download without going through your API
  • Multipart Upload - Handling large files more reliably

Stay tuned, I'll cover these features soon.

If you want to check out examples I created, you can find the source code here:

Source Code

I hope you enjoyed it, subscribe and get a notification when a new blog is up!