S3 from .NET
S3 looks simple. Put objects, get objects, list objects. But in a real application you need presigned URLs (so clients upload directly without proxying through your API), multipart uploads for large files, streaming downloads without loading everything into memory, and event-driven processing when files land.
The .NET SDK for S3 is mature and well-designed. Most operations are simple.
Basic Operations
Setup
using Amazon.S3;
using Amazon.S3.Model;
var s3Client = new AmazonS3Client(); // uses default credentials chain
Upload an object
await s3Client.PutObjectAsync(new PutObjectRequest
{
BucketName = "my-bucket",
Key = $"uploads/{userId}/{fileName}",
InputStream = fileStream,
ContentType = "application/pdf",
Metadata =
{
["x-amz-meta-uploaded-by"] = userId,
["x-amz-meta-original-name"] = fileName,
},
});
Download an object
var response = await s3Client.GetObjectAsync(new GetObjectRequest
{
BucketName = "my-bucket",
Key = "uploads/user123/report.pdf",
});
// Stream it β don't load the whole thing into memory
await using var responseStream = response.ResponseStream;
await responseStream.CopyToAsync(outputStream);
Check if an object exists
try
{
await s3Client.GetObjectMetadataAsync(new GetObjectMetadataRequest
{
BucketName = "my-bucket",
Key = "uploads/user123/report.pdf",
});
// exists
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// doesn't exist
}
Presigned URLs
Presigned URLs let clients upload or download directly to/from S3 without your API proxying the bytes. Essential for large files. Your Lambda doesn't need the memory or the 30-second timeout.
Presigned upload URL
var request = new GetPreSignedUrlRequest
{
BucketName = "my-bucket",
Key = $"uploads/{userId}/{Guid.NewGuid()}/{fileName}",
Verb = HttpVerb.PUT,
Expires = DateTime.UtcNow.AddMinutes(15),
ContentType = contentType,
};
var url = s3Client.GetPreSignedURL(request);
// Return this URL to the client β they PUT directly to S3
Presigned download URL
var request = new GetPreSignedUrlRequest
{
BucketName = "my-bucket",
Key = objectKey,
Verb = HttpVerb.GET,
Expires = DateTime.UtcNow.AddMinutes(60),
ResponseHeaderOverrides = new ResponseHeaderOverrides
{
ContentDisposition = $"attachment; filename=\"{fileName}\"",
},
};
var url = s3Client.GetPreSignedURL(request);
Presigned POST (for browser form uploads)
For browser-based uploads with size limits, content type restrictions, and other conditions:
// Note: presigned POST is not directly in the SDK β use presigned PUT
// with CORS configured on the bucket, or use the Transfer Utility
Multipart Uploads
For files over 100MB, multipart uploads are more reliable (each part can be retried independently) and faster (parts upload in parallel).
Using TransferUtility (recommended)
using Amazon.S3.Transfer;
var transferUtility = new TransferUtility(s3Client);
await transferUtility.UploadAsync(new TransferUtilityUploadRequest
{
BucketName = "my-bucket",
Key = "large-files/backup.zip",
FilePath = "/tmp/backup.zip", // or use InputStream
PartSize = 10 * 1024 * 1024, // 10MB parts
CannedACL = S3CannedACL.Private,
});
TransferUtility handles multipart upload/download automatically, including retries for individual parts.
Processing S3 Events with Lambda
When a file lands in S3, trigger a Lambda to process it:
using Amazon.Lambda.S3Events;
public async Task Handler(S3Event s3Event, ILambdaContext context)
{
foreach (var record in s3Event.Records)
{
var bucket = record.S3.Bucket.Name;
var key = Uri.UnescapeDataString(record.S3.Object.Key);
var size = record.S3.Object.Size;
context.Logger.LogInformation($"Processing {key} ({size} bytes) from {bucket}");
var response = await s3Client.GetObjectAsync(bucket, key);
await using var stream = response.ResponseStream;
// Process the file...
await ProcessFileAsync(stream, key);
}
}
Important: Always URL-decode the key. S3 event notifications URL-encode special characters (spaces become +, etc.).
CDK Setup (C#)
using Amazon.CDK;
using Amazon.CDK.AWS.S3;
using Amazon.CDK.AWS.Lambda.EventSources;
var bucket = new Bucket(this, "UploadsBucket", new BucketProps
{
BucketName = "my-app-uploads",
Encryption = BucketEncryption.S3_MANAGED,
BlockPublicAccess = BlockPublicAccess.BLOCK_ALL,
RemovalPolicy = RemovalPolicy.RETAIN,
LifecycleRules = new[]
{
new LifecycleRule
{
// Move to Infrequent Access after 30 days
Transitions = new[]
{
new Transition
{
StorageClass = StorageClass.INFREQUENT_ACCESS,
TransitionAfter = Duration.Days(30),
},
},
// Delete after 1 year
Expiration = Duration.Days(365),
},
},
Cors = new[]
{
new CorsRule
{
AllowedMethods = new[] { HttpMethods.PUT, HttpMethods.GET },
AllowedOrigins = new[] { "https://myapp.com" },
AllowedHeaders = new[] { "*" },
MaxAge = 3600,
},
},
});
// Trigger Lambda on upload
processorFunction.AddEventSource(new S3EventSource(bucket, new S3EventSourceProps
{
Events = new[] { EventType.OBJECT_CREATED },
Filters = new[] { new NotificationKeyFilter { Prefix = "uploads/" } },
}));
// Grant presigned URL generation
bucket.GrantReadWrite(apiFunction);
Tips for .NET Developers
- Always stream, never buffer large files.
GetObjectResponse.ResponseStreamlets you process data without loading it all into memory. Critical in Lambda where memory = cost. - Use
TransferUtilityfor anything over 10MB. It handles multipart automatically and is more resilient thanPutObjectAsyncfor large files. - S3 is eventually consistent for overwrites and deletes. If you PUT to the same key twice quickly, readers might get the old version briefly. Plan for this in your application logic.
- Don't use S3 as a database. Listing objects is slow and expensive at scale. Store metadata in DynamoDB and use S3 for the actual blobs.
- Enable versioning for anything important. It protects against accidental overwrites and deletions.
Further Reading
Looking for hands-on help? View my .NET on AWS services β