Free .NET Web API Course

S3 Versioning in .NET - Protect Against Accidental Deletes and Recover Any File Version

Learn how to enable S3 versioning, list previous versions, restore deleted files, and permanently delete versions using .NET. Build a minimal API that gives you complete control over your S3 object history.

dotnet aws

s3 versioning storage backup disaster-recovery

11 min read

You uploaded a critical configuration file to S3. Someone on your team accidentally overwrote it with the wrong version. Or worse—deleted it entirely. Without versioning, that file is gone forever.

S3 Versioning solves this. Every time you upload, overwrite, or delete an object, S3 keeps the previous version. You can list all versions, restore any previous state, and even recover “deleted” files. It’s like Git for your S3 objects.

In this article, we’ll build a .NET minimal API that enables versioning, uploads files, lists all versions of an object, restores previous versions, recovers deleted files, and permanently deletes specific versions. You’ll see exactly how versioning works under the hood and have working code to integrate into your own projects.

The complete source code is available at github.com/iammukeshm/s3-versioning-restore-dotnet.

Thank You, AWS! 🚀

This article is sponsored by AWS. Huge thanks for helping me produce more .NET on AWS content!

Sponsored Content

What is S3 Versioning?

When you enable versioning on an S3 bucket, every object gets a unique version ID. Here’s what happens:

ActionWithout VersioningWith Versioning
Upload new fileCreates objectCreates object with version ID
Overwrite fileReplaces object (old is lost)Creates new version (old is preserved)
Delete fileObject is goneAdds “delete marker” (object appears deleted but isn’t)
Permanent deleteN/AMust specify version ID to truly delete

The key insight: with versioning enabled, you cannot accidentally lose data. Overwrites create new versions. Deletes add markers that hide the object but don’t remove it. Only explicit version-specific deletes permanently remove data.

Version IDs

Every version has a unique ID like 3sL4kqtJlcpXroDTDmJ.XpBQkZ8F.hOq. When you fetch an object without specifying a version, S3 returns the latest version. To access a specific version, you include the version ID in your request.

Delete Markers

When you delete an object in a versioned bucket, S3 doesn’t actually delete anything. It adds a delete marker—a special version that tells S3 “this object is deleted.” The object appears gone when you list the bucket normally, but all previous versions still exist. To restore, you simply delete the delete marker.

Prerequisites

  • .NET 10 SDK
  • AWS Account with S3 access
  • AWS CLI configured with credentials
  • Visual Studio 2026 or VS Code

Required NuGet Package

<PackageReference Include="AWSSDK.S3" Version="4.0.16" />

Step 1: Create and Configure the S3 Bucket

Via AWS Console

  1. Go to S3Create bucket
  2. Name it my-versioned-bucket-demo (must be globally unique)
  3. Region: us-east-1 (or your preferred region)
  4. Under Bucket Versioning, select Enable
  5. Leave other settings as default → Create bucket

Create S3 bucket with versioning enabled

Via AWS CLI

Terminal window
aws s3api create-bucket --bucket my-versioned-bucket-demo --region us-east-1
aws s3api put-bucket-versioning --bucket my-versioned-bucket-demo --versioning-configuration Status=Enabled

Verify versioning is enabled:

Terminal window
aws s3api get-bucket-versioning --bucket my-versioned-bucket-demo

Expected output:

{
"Status": "Enabled"
}

Step 2: Create the .NET Project

Create a new minimal API project:

Terminal window
dotnet new webapi -n S3VersioningDemo -o S3VersioningDemo
cd S3VersioningDemo
dotnet add package AWSSDK.S3
dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCore

Step 3: Configure the Application

Update appsettings.json:

{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"AWS": {
"Region": "us-east-1",
"BucketName": "my-versioned-bucket-demo"
}
}

Step 4: Build the API

Replace Program.cs with the following:

using Amazon;
using Amazon.S3;
using Amazon.S3.Model;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var region = builder.Configuration["AWS:Region"] ?? "us-east-1";
var bucketName = builder.Configuration["AWS:BucketName"] ?? "my-versioned-bucket-demo";
builder.Services.AddSingleton<IAmazonS3>(_ => new AmazonS3Client(RegionEndpoint.GetBySystemName(region)));
var app = builder.Build();
app.MapOpenApi();
app.MapScalarApiReference();
app.MapGet("/", () => Results.Ok("S3 Versioning Demo API"));
// Upload a file (creates a new version if object exists)
app.MapPost("/files/{key}", async (string key, IFormFile file, IAmazonS3 s3) =>
{
using var stream = file.OpenReadStream();
var request = new PutObjectRequest
{
BucketName = bucketName,
Key = key,
InputStream = stream,
ContentType = file.ContentType
};
var response = await s3.PutObjectAsync(request);
return Results.Ok(new
{
Message = "File uploaded successfully",
Key = key,
VersionId = response.VersionId
});
}).DisableAntiforgery();
// List all versions of an object (including delete markers)
app.MapGet("/files/{key}/versions", async (string key, IAmazonS3 s3) =>
{
var request = new ListVersionsRequest
{
BucketName = bucketName,
Prefix = key
};
var response = await s3.ListVersionsAsync(request);
// S3ObjectVersion includes both regular versions and delete markers
// The IsDeleteMarker property indicates if it's a delete marker
var allVersions = response.Versions
.Where(v => v.Key == key)
.OrderByDescending(v => v.LastModified)
.Select(v => new
{
v.VersionId,
v.IsLatest,
v.LastModified,
Size = (v.IsDeleteMarker == true) ? 0L : v.Size,
IsDeleteMarker = v.IsDeleteMarker ?? false
})
.ToList();
return Results.Ok(new
{
Key = key,
TotalVersions = allVersions.Count,
Versions = allVersions
});
});
// Download a specific version
app.MapGet("/files/{key}/versions/{versionId}", async (string key, string versionId, IAmazonS3 s3) =>
{
try
{
var request = new GetObjectRequest
{
BucketName = bucketName,
Key = key,
VersionId = versionId
};
var response = await s3.GetObjectAsync(request);
return Results.Stream(response.ResponseStream, response.Headers.ContentType, key);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return Results.NotFound(new { Message = "Version not found", Key = key, VersionId = versionId });
}
});
// Download the latest version
app.MapGet("/files/{key}", async (string key, IAmazonS3 s3) =>
{
try
{
var request = new GetObjectRequest
{
BucketName = bucketName,
Key = key
};
var response = await s3.GetObjectAsync(request);
return Results.Stream(response.ResponseStream, response.Headers.ContentType, key);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return Results.NotFound(new { Message = "File not found or deleted", Key = key });
}
});
// Soft delete (adds delete marker)
app.MapDelete("/files/{key}", async (string key, IAmazonS3 s3) =>
{
var request = new DeleteObjectRequest
{
BucketName = bucketName,
Key = key
};
var response = await s3.DeleteObjectAsync(request);
return Results.Ok(new
{
Message = "Delete marker added (file hidden but not permanently deleted)",
Key = key,
DeleteMarkerVersionId = response.VersionId
});
});
// Restore a previous version (copies old version to become the new latest)
app.MapPost("/files/{key}/restore/{versionId}", async (string key, string versionId, IAmazonS3 s3) =>
{
var copyRequest = new CopyObjectRequest
{
SourceBucket = bucketName,
SourceKey = key,
SourceVersionId = versionId,
DestinationBucket = bucketName,
DestinationKey = key
};
var response = await s3.CopyObjectAsync(copyRequest);
return Results.Ok(new
{
Message = "Version restored successfully",
Key = key,
RestoredFromVersionId = versionId,
NewVersionId = response.VersionId
});
});
// Recover a deleted file (removes the delete marker)
app.MapPost("/files/{key}/recover", async (string key, IAmazonS3 s3) =>
{
// Find the delete marker
var listRequest = new ListVersionsRequest
{
BucketName = bucketName,
Prefix = key
};
var listResponse = await s3.ListVersionsAsync(listRequest);
// Find the latest delete marker for this key
var deleteMarker = listResponse.Versions
.FirstOrDefault(v => v.Key == key && v.IsLatest == true && v.IsDeleteMarker == true);
if (deleteMarker == null)
{
return Results.BadRequest(new { Message = "No delete marker found - file is not deleted", Key = key });
}
// Delete the delete marker to restore the object
var deleteRequest = new DeleteObjectRequest
{
BucketName = bucketName,
Key = key,
VersionId = deleteMarker.VersionId
};
await s3.DeleteObjectAsync(deleteRequest);
return Results.Ok(new
{
Message = "File recovered successfully",
Key = key,
RemovedDeleteMarkerVersionId = deleteMarker.VersionId
});
});
// Permanently delete a specific version
app.MapDelete("/files/{key}/versions/{versionId}", async (string key, string versionId, IAmazonS3 s3) =>
{
var request = new DeleteObjectRequest
{
BucketName = bucketName,
Key = key,
VersionId = versionId
};
await s3.DeleteObjectAsync(request);
return Results.Ok(new
{
Message = "Version permanently deleted",
Key = key,
DeletedVersionId = versionId
});
});
// Permanently delete ALL versions of an object
app.MapDelete("/files/{key}/all-versions", async (string key, IAmazonS3 s3) =>
{
var listRequest = new ListVersionsRequest
{
BucketName = bucketName,
Prefix = key
};
var listResponse = await s3.ListVersionsAsync(listRequest);
// Get all versions (including delete markers) for this specific key
var objectsToDelete = listResponse.Versions
.Where(v => v.Key == key)
.Select(v => new KeyVersion { Key = v.Key, VersionId = v.VersionId })
.ToList();
if (objectsToDelete.Count == 0)
{
return Results.NotFound(new { Message = "No versions found", Key = key });
}
var deleteRequest = new DeleteObjectsRequest
{
BucketName = bucketName,
Objects = objectsToDelete
};
var response = await s3.DeleteObjectsAsync(deleteRequest);
return Results.Ok(new
{
Message = "All versions permanently deleted",
Key = key,
DeletedCount = response.DeletedObjects.Count
});
});
app.Run();

Code Walkthrough

POST /files/{key} Uploads a file to S3 with the given key. If versioning is enabled (which it is), S3 automatically assigns a version ID. Uploading to the same key creates a new version while preserving the old one.

GET /files/{key}/versions Lists all versions of an object, including delete markers. This is how you see the complete history of a file—every upload, overwrite, and delete. The response shows version IDs, timestamps, sizes, and which version is current.

GET /files/{key}/versions/{versionId} Downloads a specific version by its ID. This is how you access old versions even after the object has been overwritten multiple times.

GET /files/{key} Downloads the latest version. If the object has a delete marker as the latest version, this returns 404—the object appears deleted even though previous versions exist.

DELETE /files/{key} Adds a delete marker. The object appears deleted to normal GET requests, but all versions remain intact. This is a “soft delete” that can be undone.

POST /files/{key}/restore/{versionId} Restores a previous version by copying it to become the new latest version. The old version remains untouched; a new version is created with the same content.

POST /files/{key}/recover Finds and removes the delete marker, effectively “undeleting” the file. The object becomes visible again with its previous latest version restored.

DELETE /files/{key}/versions/{versionId} Permanently deletes a specific version. This is irreversible—once a version is deleted with its version ID specified, it’s gone.

DELETE /files/{key}/all-versions Nuclear option: permanently deletes every version and delete marker for an object. Use with caution.

Step 5: Test the API

Run the application:

Terminal window
dotnet run

Open Scalar UI at https://localhost:5001/scalar/v1 (or your configured port).

Test Scenario: Complete Versioning Workflow

1. Upload the initial version

I created a file config.json with content:

{
"version": 1
}

Response:

{
"message": "File uploaded successfully",
"key": "config",
"versionId": "3sL4kqtJlcpXroDTDmJ.XpBQkZ8F.hOq"
}

2. Upload a second version (overwrite)

I modified the config.json file, incremented the version number, and uploaded again:

Response:

{
"message": "File uploaded successfully",
"key": "config",
"versionId": "9Xnz.M7yZ5hJ2kLpQrS.AbCdEfGh.IjK"
}

3. List all versions

Terminal window
curl "https://localhost:5001/files/config/versions"

Response:

{
"key": "config",
"totalVersions": 2,
"versions": [
{
"versionId": "zlF7oUfsswhwJrN1y1eTYKzOVlG_j8Vp",
"isLatest": true,
"lastModified": "2025-12-30T06:10:13Z",
"size": 22,
"isDeleteMarker": false
},
{
"versionId": "AqBPh_tzleT.QxWkYPfm4WhnRpAwzsaQ",
"isLatest": false,
"lastModified": "2025-12-30T06:09:13Z",
"size": 22,
"isDeleteMarker": false
}
]
}

List versions response in Scalar

4. Restore the first version

Terminal window
curl -X POST "https://localhost:5001/files/config/restore/3sL4kqtJlcpXroDTDmJ.XpBQkZ8F.hOq"

Response:

{
"message": "Version restored successfully",
"key": "config",
"restoredFromVersionId": "AqBPh_tzleT.QxWkYPfm4WhnRpAwzsaQ",
"newVersionId": "QlYX8HOS2GOFZq0djw1OLx7esMKOAKoj"
}

Now config has 3 versions, with the newest being a copy of the original.

5. Delete the file (soft delete)

Terminal window
curl -X DELETE "https://localhost:5001/files/config"

Response:

{
"message": "Delete marker added (file hidden but not permanently deleted)",
"key": "config",
"deleteMarkerVersionId": "MtGkBRNsfupSAWuSBivE3Uhwa3WNcPR5"
}

6. Try to download (will fail)

Terminal window
curl "https://localhost:5001/files/config"

Response: 404 Not Found

{
"message": "File not found or deleted",
"key": "config"
}

7. Recover the deleted file

Terminal window
curl -X POST "https://localhost:5001/files/config/recover"

Response:

{
"message": "File recovered successfully",
"key": "config",
"removedDeleteMarkerVersionId": "MtGkBRNsfupSAWuSBivE3Uhwa3WNcPR5"
}

8. Download works again

Terminal window
curl "https://localhost:5001/files/config"

Returns the file content.

Recover deleted file response

Cost Implications

Versioning increases storage costs because every version is stored separately. Consider these factors:

FactorImpact
StorageEvery version consumes storage space
RequestsListing versions, restoring = additional API calls
Data transferDownloading old versions incurs transfer costs

Cost Optimization with Lifecycle Policies

Use S3 Lifecycle policies to automatically manage old versions:

{
"Rules": [
{
"ID": "DeleteOldVersions",
"Status": "Enabled",
"NoncurrentVersionExpiration": {
"NoncurrentDays": 30
}
}
]
}

This automatically deletes versions older than 30 days while keeping the current version.

MFA Delete for Extra Protection

For critical data, enable MFA Delete. This requires multi-factor authentication to:

  • Permanently delete any version
  • Change the versioning state of the bucket

Enable via CLI (requires root account):

Terminal window
aws s3api put-bucket-versioning --bucket my-versioned-bucket-demo --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa "arn:aws:iam::123456789012:mfa/root-account-mfa-device 123456"

With MFA Delete enabled, even if credentials are compromised, attackers cannot permanently delete your data without the MFA device.

IAM Permissions

Your application needs these permissions:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:ListBucketVersions"
],
"Resource": [
"arn:aws:s3:::my-versioned-bucket-demo",
"arn:aws:s3:::my-versioned-bucket-demo/*"
]
}
]
}

Key permissions explained:

  • GetObjectVersion - Required to download specific versions
  • DeleteObjectVersion - Required to permanently delete versions or remove delete markers
  • ListBucketVersions - Required to list all versions of objects

Common Patterns

Audit Trail for Configuration Files

Store configuration changes with metadata:

var request = new PutObjectRequest
{
BucketName = bucketName,
Key = "config/app-settings.json",
InputStream = stream,
Metadata =
{
["x-amz-meta-changed-by"] = userId,
["x-amz-meta-change-reason"] = "Updated database connection string"
}
};

Rollback to Last Known Good

public async Task<string> RollbackToLastGoodVersion(string key, Func<Stream, bool> validator)
{
var versions = await ListVersionsAsync(key);
foreach (var version in versions.Where(v => !v.IsDeleteMarker))
{
var content = await GetVersionAsync(key, version.VersionId);
if (validator(content))
{
await RestoreVersionAsync(key, version.VersionId);
return version.VersionId;
}
}
throw new InvalidOperationException("No valid version found");
}

Scheduled Version Cleanup

For buckets without lifecycle policies, clean up old versions programmatically:

public async Task CleanupOldVersions(string key, int keepCount)
{
var versions = await ListVersionsAsync(key);
var toDelete = versions
.Where(v => !v.IsLatest && !v.IsDeleteMarker)
.OrderByDescending(v => v.LastModified)
.Skip(keepCount)
.ToList();
foreach (var version in toDelete)
{
await PermanentlyDeleteVersionAsync(key, version.VersionId);
}
}

Wrap-Up

S3 Versioning is your safety net against data loss. With versioning enabled:

  • Overwrites are reversible - Every upload creates a new version
  • Deletes are recoverable - Delete markers hide objects but don’t destroy them
  • You have complete history - Access any previous version by its ID
  • Permanent deletion requires intent - Must specify version ID to truly delete

The API we built gives you full control over versioned objects: upload, list versions, restore previous versions, recover deleted files, and permanently delete when needed.

Key takeaways:

  • Enable versioning on buckets containing important data
  • Use lifecycle policies to control storage costs
  • Consider MFA Delete for critical buckets
  • Include version-related permissions in your IAM policies
  • Build restore and recovery into your application from the start

Grab the complete source code from github.com/iammukeshm/s3-versioning-restore-dotnet and start protecting your S3 data today.

Have questions about versioning patterns or cost optimization? Drop a comment below—I’d love to hear how you’re using S3 versioning in your .NET applications.

Happy Coding :)

What's your Feedback?
Do let me know your thoughts around this article.

Level Up Your .NET Skills

Join 8,000+ developers. Get one practical tip each week with best practices and real-world examples.

Weekly tips
Code examples
100% free
No spam, unsubscribe anytime