VOOZH about

URL: https://codewithmukesh.com/blog/s3-lifecycle-policies-dotnet/

⇱ S3 Object Lifecycle Policies with .NET - Automate Storage Management & Cost Optimization - codewithmukesh


Skip to main content
Article complete

Get one like this every Tuesday at 7 PM IST.

Back to blog
dotnet aws 30 min read Lesson 18/57

S3 Object Lifecycle Policies with .NET - Automate Storage Management & Cost Optimization

Learn how to automate S3 storage management using lifecycle policies in .NET. Build a Blazor WASM app to upload files, view bucket contents with storage class info, and manage lifecycle rules programmatically using predefined templates.

Learn how to automate S3 storage management using lifecycle policies in .NET. Build a Blazor WASM app to upload files, view bucket contents with storage class info, and manage lifecycle rules programmatically using predefined templates.

dotnet aws

s3 lifecycle storage cost optimization blazor

👁 Mukesh Murugan
Mukesh Murugan
Software Engineer
Chapter · 18 of 57 Module 4 of 15 Free
View course

AWS for .NET Developers

From dotnet new to docker push — REST, EF Core 10, auth, caching, Clean Architecture, observability. 57 hands-on lessons, source on GitHub.

You’ve been running your application for months. Users upload files, your system generates logs, and temporary data accumulates. One day you check your AWS bill and realize S3 storage costs have quietly ballooned. Sound familiar?

The problem isn’t S3 - it’s manual storage management. Deleting old files by hand doesn’t scale. Moving infrequently accessed data to cheaper storage tiers? Nobody has time for that.

The solution: S3 Lifecycle Policies. These are automated rules that transition objects between storage classes and delete them when they’re no longer needed. Set them once, and AWS handles everything - forever.

In this article, we’ll build a complete system: a .NET API that manages lifecycle policies programmatically, and a Blazor WASM client that lets you upload files, view bucket contents with storage class information, and create lifecycle rules from predefined templates.

The complete source code for this article is available on GitHub.

Prerequisites

Before diving in, make sure you’re comfortable with basic S3 operations. If you’re new to S3, start with these articles:

Read nextCompanion article

Working with AWS S3 using ASP.NET Core

Comprehensive guide covering bucket operations, file uploads, and downloads with the AWS SDK.

Read nextCompanion article

AWS S3 Presigned URLs for .NET

Learn how presigned URLs enable direct browser uploads without exposing your bucket.

Read nextCompanion article

Configuring AWS Credentials for .NET Applications

Set up your development environment with proper AWS authentication using CLI profiles.

You’ll also need:

  • .NET 10 SDK
  • AWS account with S3 access
  • AWS CLI configured with credentials
  • Visual Studio 2022/VS Code

What Are S3 Lifecycle Policies?

S3 Lifecycle Policies are automated rules that manage objects throughout their lifecycle. Instead of manually moving or deleting files, you define rules and AWS executes them automatically.

A lifecycle policy consists of:

  • Rules: Named configurations that define what happens to objects
  • Filters: Prefix and/or tag-based selectors that target specific objects
  • Transitions: Actions that move objects between storage classes
  • Expiration: Actions that delete objects after a specified period

How Lifecycle Evaluation Works

AWS evaluates lifecycle rules once per day at midnight UTC. This means:

  • Changes aren’t immediate - expect up to 24 hours for rules to take effect
  • Objects uploaded today won’t transition until the rule evaluates tomorrow (at earliest)
  • The “days” counter starts from the object’s creation date

Lifecycle policies are eventually consistent. Don’t expect real-time transitions - plan for daily batch processing.

S3 Storage Classes Explained

Before creating lifecycle rules, you need to understand where objects can transition to. S3 offers multiple storage classes optimized for different access patterns:

Storage ClassBest ForRetrieval TimeMin DurationRelative Cost
StandardFrequently accessed dataInstantNone$$$$$
Standard-IAInfrequent access, immediate needInstant30 days$$$
One Zone-IAReproducible infrequent dataInstant30 days$$
Glacier Instant RetrievalArchive with instant accessMilliseconds90 days$$
Glacier Flexible RetrievalArchive, flexible timing1-12 hours90 days$
Glacier Deep ArchiveLong-term compliance archive12-48 hours180 days¢
Intelligent-TieringUnknown/changing access patternsAutoNoneAuto-optimized

Key Considerations

Minimum storage duration: If you delete or transition an object before the minimum duration, you’re still charged for the full period. Transitioning a file to Glacier and deleting it after 30 days? You pay for 90 days.

Retrieval costs: Cheaper storage classes have retrieval fees. Glacier Deep Archive is incredibly cheap for storage but expensive to retrieve frequently.

Transition waterfall: Objects can only transition “downward” in terms of access frequency. You can’t transition from Glacier back to Standard via lifecycle rules (that requires a restore operation).

Common Lifecycle Patterns

Here are four practical patterns we’ll implement as templates in our application:

Pattern 1: Archive Logs After 30 Days

Application logs are accessed frequently when debugging recent issues but rarely needed after a month.

logs/* → Standard-IA (30 days) → Glacier Flexible (90 days) → Delete (365 days)

This pattern:

  • Keeps recent logs instantly accessible
  • Moves older logs to cheaper storage
  • Automatically deletes logs after one year

Pattern 2: Delete Temp Files After 7 Days

Temporary files, upload chunks, and processing artifacts should be cleaned up automatically.

temp/* → Delete (7 days)

Simple and effective - no transitions, just deletion.

Pattern 3: Move to Glacier After 90 Days

For data that must be retained but is rarely accessed (compliance, legal holds, historical records).

archive/* → Glacier Flexible Retrieval (90 days)

Pattern 4: Cleanup Incomplete Multipart Uploads

Failed or abandoned multipart uploads consume storage indefinitely. This rule cleans them up.

Abort incomplete multipart uploads after 7 days

This is often overlooked but can save significant costs in high-upload-volume applications, especially if you rely on multipart uploads for large files where interrupted transfers leave orphaned parts behind.

Creating Lifecycle Policies via AWS Console

Before we automate everything with .NET, let’s walk through creating a lifecycle rule manually. This helps you understand what the SDK is doing behind the scenes.

Step 1: Navigate to Your Bucket

Open the S3 console, select your bucket, and click the Management tab.

👁 S3 bucket Management tab

Step 2: Create a Lifecycle Rule

Click Create lifecycle rule. You’ll see a form with several sections.

👁 Create lifecycle rule form

Configure the following:

  • Rule name: archive-logs-after-30-days
  • Rule scope: Choose “Limit the scope using filters”
  • Prefix: logs/

Step 3: Configure Transitions

Under “Lifecycle rule actions”, check:

  • ✅ Transition current versions of objects between storage classes
  • ✅ Expire current versions of objects

Add transitions:

  • Move to Standard-IA after 30 days
  • Move to Glacier Flexible Retrieval after 90 days

Set expiration:

  • Expire objects after 365 days

👁 Configure lifecycle transitions

Step 4: Review and Create

Review your configuration and click Create rule. The rule is now active and will be evaluated daily at midnight UTC.

👁 Configure lifecycle transitions

Solution Architecture

Now let’s build a complete system to manage lifecycle policies programmatically. Here’s what we’re building:

The Blazor client handles:

  • File uploads via presigned URLs (anonymous, direct to S3)
  • Displaying files with storage class and transition estimates
  • Creating/deleting lifecycle rules from templates

The .NET API handles:

  • Generating presigned URLs for uploads
  • Listing objects with metadata
  • CRUD operations on lifecycle configuration
  • Seeding demo data for testing

Building the .NET API

Let’s start with the backend API. Create a new .NET 10 minimal API project.

Want to skip ahead? Clone the complete solution from GitHub and follow along with the finished code.

Project Setup

Create the solution structure:

Terminal window
dotnetnewweb-nS3LifecyclePolicies.Api-fnet10.0
dotnetnewblazorwasm-nS3LifecyclePolicies.Client-fnet10.0
dotnetnewclasslib-nS3LifecyclePolicies.Shared-fnet10.0

Add the required NuGet packages to the API project:

Terminal window
cdS3LifecyclePolicies.Api
dotnetaddpackageAWSSDK.S3
dotnetaddpackageAWSSDK.Extensions.NETCore.Setup
dotnetaddpackageMicrosoft.AspNetCore.OpenApi
dotnetaddpackageScalar.AspNetCore

Configuration

Add S3 settings to appsettings.json:

{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"AWS": {
"Profile": "default",
"Region": "us-east-1"
},
"S3": {
"BucketName": "your-lifecycle-demo-bucket"
}
}

Shared Models

Before diving into the services, we need to define the data contracts that both the API and Blazor client will use. By placing these in a shared project, we ensure type safety across the entire stack - the same models serialize on the server and deserialize on the client.

File Information Model

When listing files, we want to show more than just the filename. The S3FileInfo record captures everything a user needs to understand a file’s lifecycle status:

Models/S3FileInfo.cs
namespaceS3LifecyclePolicies.Shared.Models;
publicrecordS3FileInfo(
stringKey,
longSize,
stringStorageClass,
DateTimeLastModified,
string? NextTransition,
DateTime? ExpirationDate
);
  • Key: The full S3 object path (e.g., logs/app-2024-01-15.log)
  • StorageClass: Current storage tier - helps users understand current costs
  • NextTransition: A human-readable string like “GLACIER in 45 days” - we calculate this by comparing the object’s age against matching lifecycle rules
  • ExpirationDate: When this file will be automatically deleted (if applicable)

Lifecycle Rule DTOs

The AWS SDK returns lifecycle rules in a complex nested structure. We flatten this into a simpler DTO that’s easy to display in the UI:

Models/LifecycleRuleDto.cs
namespaceS3LifecyclePolicies.Shared.Models;
publicrecordLifecycleRuleDto(
stringId,
string? Prefix,
stringStatus,
List<TransitionDto> Transitions,
int? ExpirationDays
);
publicrecordTransitionDto(
intDays,
stringStorageClass
);

The LifecycleRuleDto captures the essential rule properties: which prefix it targets, what transitions it applies, and when objects expire. The TransitionDto pairs a day count with a target storage class - for example, “move to GLACIER after 90 days”.

Lifecycle Templates

Rather than exposing the full complexity of lifecycle rule creation (filters, predicates, transitions, expiration), we use an enum-based template system. This gives users preset configurations that cover common scenarios:

Models/LifecycleTemplate.cs
namespaceS3LifecyclePolicies.Shared.Models;
publicenumLifecycleTemplate
{
ArchiveLogsAfter30Days,
DeleteTempFilesAfter7Days,
MoveToGlacierAfter90Days,
CleanupIncompleteUploads
}
publicrecordCreateLifecycleRuleRequest(LifecycleTemplateTemplate);

The CreateLifecycleRuleRequest wraps the template enum. This wrapper is necessary because ASP.NET Core’s minimal API parameter binding needs a concrete type to deserialize from the JSON request body - raw enums don’t bind correctly.

Upload Request/Response

For presigned URL uploads, we need to exchange file metadata with the server:

Models/UploadUrlRequest.cs
namespaceS3LifecyclePolicies.Shared.Models;
publicrecordUploadUrlRequest(
stringFileName,
stringPrefix,
stringContentType
);
publicrecordUploadUrlResponse(
stringUrl,
stringKey
);

The client sends the filename, target prefix (logs/, temp/, archive/), and content type. The server returns a presigned URL the browser can PUT directly to S3, along with the final object key. This approach lets users upload files without our server ever touching the file bytes - reducing bandwidth costs and latency.

S3 Service

The S3 service handles file operations: listing objects with their storage metadata, generating presigned URLs for uploads, and seeding test data. Let’s start with the interface that defines what this service can do:

Services/IS3Service.cs
usingS3LifecyclePolicies.Shared.Models;
namespaceS3LifecyclePolicies.Api.Services;
publicinterfaceIS3Service
{
Task<List<S3FileInfo>> ListFilesAsync(CancellationTokencancellationToken=default);
Task<UploadUrlResponse> GenerateUploadUrlAsync(UploadUrlRequestrequest, CancellationTokencancellationToken=default);
TaskSeedDemoDataAsync(CancellationTokencancellationToken=default);
}

Now for the implementation. The service uses primary constructor syntax to inject dependencies: the AWS S3 client, configuration for the bucket name, and the lifecycle service (we’ll need it to calculate transition estimates).

Services/S3Service.cs
usingAmazon.S3;
usingAmazon.S3.Model;
usingS3LifecyclePolicies.Shared.Models;
namespaceS3LifecyclePolicies.Api.Services;
publicclassS3Service(IAmazonS3s3Client, IConfigurationconfiguration, ILifecycleServicelifecycleService) : IS3Service
{
privatereadonlystring_bucketName=configuration["S3:BucketName"]
??thrownewInvalidOperationException("S3:BucketName not configured");

Listing Files with Lifecycle Information

The ListFilesAsync method does more than list objects - it enriches each file with lifecycle predictions. Here’s the key insight: S3 doesn’t tell you when an object will transition. We have to calculate it ourselves by combining the object’s age with the active lifecycle rules.

publicasyncTask<List<S3FileInfo>> ListFilesAsync(CancellationTokencancellationToken=default)
{
varrequest=newListObjectsV2Request
{
BucketName=_bucketName
};
varresponse=awaits3Client.ListObjectsV2Async(request, cancellationToken);
// Fetch current lifecycle rules to calculate transition estimates
varrules=awaitlifecycleService.GetLifecycleRulesAsync(cancellationToken);
returnresponse.S3Objects.Select(obj=>
{
var (nextTransition, expirationDate) =CalculateTransitions(obj, rules);
returnnewS3FileInfo(
obj.Key,
obj.Size,
obj.StorageClass?.Value??"STANDARD",
obj.LastModified,
nextTransition,
expirationDate
);
}).ToList();
}

Note on AWS SDK v4: Properties like obj.Size, obj.StorageClass, and obj.LastModified are nullable in v4 of the SDK. We use null coalescing (??) to provide sensible defaults.

The transition calculation logic finds the first matching rule based on the object’s key prefix, then determines which transitions haven’t happened yet:

privatestatic (string? NextTransition, DateTime? ExpirationDate) CalculateTransitions(
S3Objectobj,
List<LifecycleRuleDto> rules)
{
// Find the first enabled rule whose prefix matches this object's key
varmatchingRule=rules.FirstOrDefault(r=>
r.Status=="Enabled"&&
!string.IsNullOrEmpty(r.Prefix) &&
obj.Key.StartsWith(r.Prefix));
if (matchingRule==null)
return (null, null);
// Calculate how old this object is (in days)
varobjectAge= (DateTime.UtcNow-obj.LastModified).Days;
// Find transitions that haven't happened yet (Days > current age)
string? nextTransition=null;
varpendingTransitions=matchingRule.Transitions
.Where(t=>t.Days>objectAge)
.OrderBy(t=>t.Days)
.ToList();
if (pendingTransitions.Count>0)
{
varnext=pendingTransitions.First();
vardaysUntil=next.Days-objectAge;
nextTransition=$"{next.StorageClass} in {daysUntil} days";
}
// Calculate when this object will be deleted (if ever)
DateTime? expirationDate=null;
if (matchingRule.ExpirationDays.HasValue)
{
expirationDate=obj.LastModified.AddDays(matchingRule.ExpirationDays.Value);
}
return (nextTransition, expirationDate);
}

For example, if a file in logs/ is 45 days old and the rule says “move to GLACIER at 90 days”, this method returns “GLACIER in 45 days”.

Generating Presigned URLs

Presigned URLs let the Blazor client upload files directly to S3 without routing bytes through our API server. We generate a unique key by combining the prefix, a GUID (to prevent collisions), and the original filename:

publicTask<UploadUrlResponse> GenerateUploadUrlAsync(UploadUrlRequestrequest, CancellationTokencancellationToken=default)
{
// Build a unique key: prefix/guid_filename
varkey=$"{request.Prefix.TrimEnd('/')}/{Guid.NewGuid()}_{request.FileName}";
varpresignRequest=newGetPreSignedUrlRequest
{
BucketName=_bucketName,
Key=key,
Verb=HttpVerb.PUT, // Allow PUT operations (uploads)
Expires=DateTime.UtcNow.AddMinutes(15), // URL valid for 15 minutes
ContentType=request.ContentType
};
varurl=s3Client.GetPreSignedURL(presignRequest);
returnTask.FromResult(newUploadUrlResponse(url, key));
}

The 15-minute expiration is a security measure - if a URL leaks, it’s only useful briefly.

Seeding Demo Data

For testing lifecycle policies, we need files of varying ages. The seed method creates sample files across all three prefixes (logs, temp, archive):

publicasyncTaskSeedDemoDataAsync(CancellationTokencancellationToken=default)
{
varseedFiles=newList<(stringKey, intDaysOld)>
{
// Log files with varying ages - simulates real log accumulation
("logs/app-2024-01-15.log", 340),
("logs/app-2024-06-01.log", 200),
("logs/app-2024-10-15.log", 70),
("logs/app-2024-11-20.log", 35),
("logs/app-2024-12-20.log", 3),
// Temp files that should be cleaned up
("temp/upload-chunk-abc123.tmp", 5),
("temp/processing-xyz789.tmp", 3),
("temp/cache-data.tmp", 1),
// Archive files for long-term storage testing
("archive/report-q1-2024.pdf", 180),
("archive/report-q2-2024.pdf", 120)
};
foreach (var (key, daysOld) inseedFiles)
{
varcontent=$"Demo file created for lifecycle testing. Original age: {daysOld} days.";
varputRequest=newPutObjectRequest
{
BucketName=_bucketName,
Key=key,
ContentBody=content,
ContentType="text/plain"
};
awaits3Client.PutObjectAsync(putRequest, cancellationToken);
}
}
}

Important: The “DaysOld” values in the tuple are for documentation purposes only - they show what age we’re simulating. However, S3 uses the actual upload timestamp as LastModified. In production, these files would all appear as “just created”. For real testing of transitions, you’d need to wait for actual time to pass or use S3 Batch Operations to backdate objects.

Lifecycle Service

This is the heart of our application - the service that reads, creates, and deletes lifecycle rules on your S3 bucket. The AWS SDK provides a comprehensive API for lifecycle management, but it has some quirks we need to handle.

Services/ILifecycleService.cs
usingS3LifecyclePolicies.Shared.Models;
namespaceS3LifecyclePolicies.Api.Services;
publicinterfaceILifecycleService
{
Task<List<LifecycleRuleDto>> GetLifecycleRulesAsync(CancellationTokencancellationToken=default);
TaskCreateRuleFromTemplateAsync(LifecycleTemplatetemplate, CancellationTokencancellationToken=default);
TaskDeleteRuleAsync(stringruleId, CancellationTokencancellationToken=default);
}

Now let’s implement each method with detailed explanations.

Reading Lifecycle Configuration

The GetLifecycleRulesAsync method retrieves all lifecycle rules configured on the bucket and transforms them into our simplified DTO format:

Services/LifecycleService.cs
usingAmazon.S3;
usingAmazon.S3.Model;
usingS3LifecyclePolicies.Shared.Models;
namespaceS3LifecyclePolicies.Api.Services;
publicclassLifecycleService(IAmazonS3s3Client, IConfigurationconfiguration) : ILifecycleService
{
privatereadonlystring_bucketName=configuration["S3:BucketName"]
??thrownewInvalidOperationException("S3:BucketName not configured");
publicasyncTask<List<LifecycleRuleDto>> GetLifecycleRulesAsync(CancellationTokencancellationToken=default)
{
try
{
varrequest=newGetLifecycleConfigurationRequest
{
BucketName=_bucketName
};
varresponse=awaits3Client.GetLifecycleConfigurationAsync(request, cancellationToken);
returnresponse.Configuration.Rules.Select(r=>newLifecycleRuleDto(
r.Id,
GetPrefixFromFilter(r.Filter),
r.Status.Value,
(r.Transitions?? []).Select(t=>newTransitionDto(t.Days??0, t.StorageClass?.Value??"Unknown")).ToList(),
r.Expiration?.Days
)).ToList();
}
catch (AmazonS3Exceptionex) when (ex.ErrorCode=="NoSuchLifecycleConfiguration")
{
// This isn't an error - it just means no rules exist yet
return [];
}
}

A few important points here:

  1. NoSuchLifecycleConfiguration exception: S3 throws this specific error when a bucket has no lifecycle configuration at all. We catch it and return an empty list - this is expected behavior, not an error.
  2. Null handling for Transitions: Some rules (like “delete after 7 days”) don’t have transitions - they only have expiration. The r.Transitions property will be null in these cases, so we use ?? [] to safely handle it.
  3. AWS SDK v4 nullable types: Properties like t.Days and t.StorageClass are nullable in SDK v4. We provide defaults with the null coalescing operator.

The helper method extracts the prefix from the filter’s predicate structure:

privatestaticstring? GetPrefixFromFilter(LifecycleFilterfilter)
{
// S3 lifecycle filters use a predicate pattern
// We only support prefix-based filtering in this demo
if (filter.LifecycleFilterPredicateisLifecyclePrefixPredicateprefixPredicate)
{
returnprefixPredicate.Prefix;
}
returnnull; // Rule applies to all objects (no prefix filter)
}

Template-Based Rule Creation

Rather than exposing raw lifecycle rule construction to users, we map enum templates to fully-configured rules. This is where the real lifecycle logic lives:

publicasyncTaskCreateRuleFromTemplateAsync(LifecycleTemplatetemplate, CancellationTokencancellationToken=default)
{
varrule=CreateRuleFromTemplate(template);
awaitAddRuleToConfigurationAsync(rule, cancellationToken);
}
privatestaticLifecycleRuleCreateRuleFromTemplate(LifecycleTemplatetemplate)
{
returntemplateswitch
{

Template 1: Archive Logs After 30 Days

This is the most comprehensive template - it demonstrates a multi-stage transition pipeline:

LifecycleTemplate.ArchiveLogsAfter30Days =>newLifecycleRule
{
Id="archive-logs-after-30-days",
Filter=newLifecycleFilter
{
// Only apply to objects with the "logs/" prefix
LifecycleFilterPredicate=newLifecyclePrefixPredicate { Prefix="logs/" }
},
Status=LifecycleRuleStatus.Enabled,
Transitions=
[
// Day 30: Move to Standard-IA (cheaper, still instant access)
newLifecycleTransition
{
Days=30,
StorageClass=S3StorageClass.StandardInfrequentAccess
},
// Day 90: Move to Glacier (much cheaper, 1-12 hour retrieval)
newLifecycleTransition
{
Days=90,
StorageClass=S3StorageClass.Glacier
}
],
// Day 365: Delete the object entirely
Expiration=newLifecycleRuleExpiration { Days=365 }
},

Template 2: Delete Temp Files After 7 Days

The simplest template - no transitions, just expiration:

LifecycleTemplate.DeleteTempFilesAfter7Days =>newLifecycleRule
{
Id="delete-temp-files-after-7-days",
Filter=newLifecycleFilter
{
LifecycleFilterPredicate=newLifecyclePrefixPredicate { Prefix="temp/" }
},
Status=LifecycleRuleStatus.Enabled,
// No Transitions - objects stay in STANDARD until deletion
Expiration=newLifecycleRuleExpiration { Days=7 }
},

Template 3: Move to Glacier After 90 Days

For archive data that must be retained indefinitely but rarely accessed:

LifecycleTemplate.MoveToGlacierAfter90Days =>newLifecycleRule
{
Id="move-to-glacier-after-90-days",
Filter=newLifecycleFilter
{
LifecycleFilterPredicate=newLifecyclePrefixPredicate { Prefix="archive/" }
},
Status=LifecycleRuleStatus.Enabled,
Transitions=
[
newLifecycleTransition
{
Days=90,
StorageClass=S3StorageClass.Glacier
}
]
// No Expiration - objects live forever in Glacier
},

Template 4: Cleanup Incomplete Multipart Uploads

This one’s different - it doesn’t target objects, it targets failed upload attempts:

LifecycleTemplate.CleanupIncompleteUploads =>newLifecycleRule
{
Id="cleanup-incomplete-uploads",
Filter=newLifecycleFilter(), // Empty filter = applies to entire bucket
Status=LifecycleRuleStatus.Enabled,
AbortIncompleteMultipartUpload=newLifecycleRuleAbortIncompleteMultipartUpload
{
DaysAfterInitiation=7
}
},
_=>thrownewArgumentOutOfRangeException(nameof(template), template, "Unknown template")
};
}

When large file uploads fail midway, S3 keeps the uploaded parts. Without this rule, those orphaned parts accumulate indefinitely, silently increasing your storage costs.

Adding Rules to Existing Configuration

S3 lifecycle configuration is all-or-nothing - you can’t add a single rule. You must read the entire configuration, modify it, and write it back. This method handles that pattern:

privateasyncTaskAddRuleToConfigurationAsync(LifecycleRulenewRule, CancellationTokencancellationToken)
{
// Step 1: Get existing configuration (if any)
varexistingRules=newList<LifecycleRule>();
try
{
vargetRequest=newGetLifecycleConfigurationRequest { BucketName=_bucketName };
vargetResponse=awaits3Client.GetLifecycleConfigurationAsync(getRequest, cancellationToken);
existingRules=getResponse.Configuration.Rules;
}
catch (AmazonS3Exceptionex) when (ex.ErrorCode=="NoSuchLifecycleConfiguration")
{
// No existing configuration - we'll create one from scratch
}
// Step 2: Replace existing rule with same ID (idempotent operation)
existingRules.RemoveAll(r=>r.Id==newRule.Id);
existingRules.Add(newRule);
// Step 3: Write the complete configuration back
varputRequest=newPutLifecycleConfigurationRequest
{
BucketName=_bucketName,
Configuration=newLifecycleConfiguration
{
Rules=existingRules
}
};
awaits3Client.PutLifecycleConfigurationAsync(putRequest, cancellationToken);
}

The RemoveAll + Add pattern makes this operation idempotent - calling it twice with the same template doesn’t create duplicate rules.

Deleting Rules

Deletion follows a similar read-modify-write pattern, with one special case: if we’re deleting the last rule, we must delete the entire lifecycle configuration (S3 doesn’t allow empty configurations):

publicasyncTaskDeleteRuleAsync(stringruleId, CancellationTokencancellationToken=default)
{
vargetRequest=newGetLifecycleConfigurationRequest { BucketName=_bucketName };
vargetResponse=awaits3Client.GetLifecycleConfigurationAsync(getRequest, cancellationToken);
varrules=getResponse.Configuration.Rules;
rules.RemoveAll(r=>r.Id==ruleId);
if (rules.Count==0)
{
// S3 doesn't allow empty lifecycle configurations
// We must delete the entire configuration instead
vardeleteRequest=newDeleteLifecycleConfigurationRequest { BucketName=_bucketName };
awaits3Client.DeleteLifecycleConfigurationAsync(deleteRequest, cancellationToken);
}
else
{
// Write back the configuration without the deleted rule
varputRequest=newPutLifecycleConfigurationRequest
{
BucketName=_bucketName,
Configuration=newLifecycleConfiguration { Rules=rules }
};
awaits3Client.PutLifecycleConfigurationAsync(putRequest, cancellationToken);
}
}
}

API Endpoints

With both services implemented, we can wire them together using .NET 10’s minimal API pattern. The Program.cs file defines all endpoints, registers dependencies, and configures the application.

You can find the complete Program.cs in the GitHub repository.

Service Registration

First, we set up dependency injection for AWS services and our custom services:

Program.cs
usingAmazon.S3;
usingS3LifecyclePolicies.Api.Services;
usingS3LifecyclePolicies.Shared.Models;
usingScalar.AspNetCore;
varbuilder=WebApplication.CreateBuilder(args);
// OpenAPI documentation (available at /openapi/v1.json)
builder.Services.AddOpenApi();
// CORS - allow the Blazor client to call our API
builder.Services.AddCors(options=>
{
options.AddDefaultPolicy(policy=>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// AWS SDK configuration - reads from appsettings.json and AWS credential chain
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
builder.Services.AddAWSService<IAmazonS3>();
// Our application services - scoped lifetime for per-request instances
builder.Services.AddScoped<ILifecycleService, LifecycleService>();
builder.Services.AddScoped<IS3Service, S3Service>();
varapp=builder.Build();
app.UseCors();
app.MapOpenApi();
app.MapScalarApiReference(); // Interactive API docs at /scalar/v1

The AddDefaultAWSOptions method reads the AWS section from configuration and sets up credential resolution (profile-based in development, IAM roles in production). AddAWSService<IAmazonS3> registers the S3 client with proper lifecycle management.

Endpoint Definitions

Each endpoint is a thin layer that delegates to our services. Let’s walk through them:

Health Check - A simple endpoint to verify the API is running:

app.MapGet("/", () =>"S3 Lifecycle Policies API is running")
.WithTags("Health");

List Files - Returns all objects in the bucket with enriched lifecycle information:

app.MapGet("/files", async (IS3Services3Service, CancellationTokenct) =>
{
varfiles=awaits3Service.ListFilesAsync(ct);
returnResults.Ok(files);
})
.WithName("ListFiles")
.WithTags("Files")
.WithDescription("Lists all files in the bucket with storage class and transition information");

The CancellationToken is automatically bound from the HTTP request - if the client disconnects, the token triggers and we can stop processing early.

Generate Upload URL - Creates a presigned URL for direct-to-S3 uploads:

app.MapPost("/files/upload-url", async (UploadUrlRequestrequest, IS3Services3Service, CancellationTokenct) =>
{
varresponse=awaits3Service.GenerateUploadUrlAsync(request, ct);
returnResults.Ok(response);
})
.WithName("GenerateUploadUrl")
.WithTags("Files")
.WithDescription("Generates a presigned URL for uploading a file");

Get Lifecycle Rules - Retrieves all configured lifecycle rules:

app.MapGet("/lifecycle/rules", async (ILifecycleServicelifecycleService, CancellationTokenct) =>
{
varrules=awaitlifecycleService.GetLifecycleRulesAsync(ct);
returnResults.Ok(rules);
})
.WithName("GetLifecycleRules")
.WithTags("Lifecycle")
.WithDescription("Gets all lifecycle rules configured on the bucket");

Create Lifecycle Rule - Creates a rule from a predefined template:

app.MapPost("/lifecycle/rules", async (CreateLifecycleRuleRequestrequest, ILifecycleServicelifecycleService, CancellationTokenct) =>
{
awaitlifecycleService.CreateRuleFromTemplateAsync(request.Template, ct);
returnResults.Created($"/lifecycle/rules", new { Template=request.Template.ToString() });
})
.WithName("CreateLifecycleRule")
.WithTags("Lifecycle")
.WithDescription("Creates a lifecycle rule from a predefined template");

Note that we accept CreateLifecycleRuleRequest (a record containing the template enum), not the raw enum. This is because minimal APIs bind complex types from the JSON body, but enums alone don’t deserialize correctly without a wrapper type.

Delete Lifecycle Rule - Removes a rule by its ID:

app.MapDelete("/lifecycle/rules/{ruleId}", async (stringruleId, ILifecycleServicelifecycleService, CancellationTokenct) =>
{
awaitlifecycleService.DeleteRuleAsync(ruleId, ct);
returnResults.NoContent();
})
.WithName("DeleteLifecycleRule")
.WithTags("Lifecycle")
.WithDescription("Deletes a lifecycle rule by ID");

Seed Demo Data - Populates the bucket with test files:

app.MapPost("/seed", async (IS3Services3Service, CancellationTokenct) =>
{
awaits3Service.SeedDemoDataAsync(ct);
returnResults.Ok(new { Message="Demo data seeded successfully" });
})
.WithName("SeedDemoData")
.WithTags("Demo")
.WithDescription("Creates sample log, temp, and archive files for testing lifecycle policies");
app.Run();

The .WithTags() and .WithDescription() calls enrich the OpenAPI documentation - you’ll see these in the Scalar UI at /scalar/v1.

Building the Blazor WASM Client

The Blazor WebAssembly client runs entirely in the browser, making HTTP calls to our API. It provides an intuitive interface for:

  • Viewing files with their storage class and lifecycle predictions
  • Uploading files directly to S3 (via presigned URLs)
  • Creating and deleting lifecycle rules from templates
  • Seeding demo data for testing

The complete Blazor client code is available in the GitHub repository.

Project Setup

First, add a reference to the Shared project so we can use the same model types:

Terminal window
cdS3LifecyclePolicies.Client
dotnetaddreference../S3LifecyclePolicies.Shared

HTTP Client Configuration

The Blazor client needs an HttpClient configured with our API’s base address. Update Program.cs:

Program.cs
usingMicrosoft.AspNetCore.Components.Web;
usingMicrosoft.AspNetCore.Components.WebAssembly.Hosting;
usingS3LifecyclePolicies.Client;
varbuilder=WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
// Configure HttpClient to point to our API
// In production, use environment-specific configuration
builder.Services.AddScoped(sp=>newHttpClient
{
BaseAddress=newUri("http://localhost:5156") // Match your API's port
});
awaitbuilder.Build().RunAsync();

Port Configuration: The API port (5156 in this example) may differ on your machine. Check the console output when running the API to confirm the actual port.

Main Layout

The layout component wraps all pages with consistent styling. We use AWS’s color palette (#232f3e) for a professional look:

@inheritsLayoutComponentBase
<divclass="container">
<header>
<h1>S3 Lifecycle Manager</h1>
</header>
<main>
@Body
</main>
</div>
<style>
.container {
max-width: 1200px;
margin: 0auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
header {
border-bottom: 1pxsolid#e0e0e0;
padding-bottom: 10px;
margin-bottom: 20px;
}
h1 {
color: #232f3e; /* AWS dark blue */
margin: 0;
}
</style>

Home Page

The home page is a dashboard that brings together file listing, uploads, and lifecycle rule management. It’s structured in two panels: one for files, one for rules.

Let’s break down the key parts:

Component Structure

The page loads files on initialization and refreshes when rules change (since rule changes affect transition predictions):

@page"/"
@usingS3LifecyclePolicies.Shared.Models
@injectHttpClientHttp
<divclass="dashboard">
<divclass="panel">
<h2>Files</h2>
<divclass="actions">
<button@onclick="SeedDemoData"disabled="@isLoading">Seed Demo Data</button>
<button@onclick="LoadFiles"disabled="@isLoading">Refresh</button>
</div>
<!-- FileUpload component handles presigned URL flow -->
<FileUploadOnUploadComplete="LoadFiles" />

File List with Lifecycle Information

The file table shows each object’s current state and predicted future:

@if (files==null)
{
<p>Loading files...</p>
}
elseif (files.Count==0)
{
<p>No files found. Click "Seed Demo Data" to create sample files.</p>
}
else
{
<table>
<thead>
<tr>
<th>Key</th>
<th>Size</th>
<th>Storage Class</th>
<th>Last Modified</th>
<th>Next Transition</th>
<th>Expiration</th>
</tr>
</thead>
<tbody>
@foreach (varfileinfiles)
{
<tr>
<td>@file.Key</td>
<td>@FormatSize(file.Size)</td>
<!--Color-codedbadgeshelpvisualizestoragetiers-->
<td><spanclass="badge @GetStorageClassColor(file.StorageClass)">@file.StorageClass</span></td>
<td>@file.LastModified.ToString("yyyy-MM-dd")</td>
<td>@(file.NextTransition??"-")</td>
<td>@(file.ExpirationDate?.ToString("yyyy-MM-dd") ??"-")</td>
</tr>
}
</tbody>
</table>
}
</div>
<divclass="panel">
<h2>Lifecycle Rules</h2>
<!--Whenruleschange,wereloadfilestoupdatetransitionpredictions-->
<LifecycleRulesOnRulesChanged="LoadFiles" />
</div>
</div>
@if (!string.IsNullOrEmpty(message))
{
<divclass="message @messageType">@message</div>
}

Component Logic

The @code block contains the component’s state and event handlers:

@code{
privateList<S3FileInfo>? files;
privateboolisLoading=false;
privatestringmessage="";
privatestringmessageType="";
// Load files when the component first renders
protectedoverrideasyncTaskOnInitializedAsync()
{
awaitLoadFiles();
}
privateasyncTaskLoadFiles()
{
isLoading=true;
try
{
// GetFromJsonAsync deserializes the JSON response into our shared model
files=awaitHttp.GetFromJsonAsync<List<S3FileInfo>>("files");
}
catch (Exceptionex)
{
ShowMessage($"Error loading files: {ex.Message}", "error");
}
finally
{
isLoading=false;
}
}
privateasyncTaskSeedDemoData()
{
isLoading=true;
try
{
// POST with null body - the API doesn't need any request payload
awaitHttp.PostAsync("seed", null);
ShowMessage("Demo data seeded successfully!", "success");
awaitLoadFiles(); // Refresh the file list
}
catch (Exceptionex)
{
ShowMessage($"Error seeding data: {ex.Message}", "error");
}
finally
{
isLoading=false;
}
}
privatevoidShowMessage(stringmsg, stringtype)
{
message=msg;
messageType=type;
StateHasChanged(); // Tell Blazor to re-render with the new message
}
// Helper to format byte sizes in human-readable form
privatestaticstringFormatSize(longbytes)
{
if (bytes<1024) return$"{bytes} B";
if (bytes<1024*1024) return$"{bytes/1024.0:F1} KB";
return$"{bytes/ (1024.0*1024.0):F1} MB";
}
// Map storage classes to CSS color classes for visual distinction
privatestaticstringGetStorageClassColor(stringstorageClass) =>storageClassswitch
{
"STANDARD"=>"green", // Hot storage - most expensive
"STANDARD_IA"=>"blue", // Infrequent access
"GLACIER"=>"purple", // Cold storage
"DEEP_ARCHIVE"=>"gray", // Coldest storage
_=>"default"
};
}

The styling uses color-coded badges to help users quickly identify which storage tier each file is in - green for Standard (hot), blue for Standard-IA (warm), purple for Glacier (cold).

<style>
.dashboard {
display: grid;
gap: 20px;
}
.panel {
background: #f9f9f9;
border: 1pxsolid#e0e0e0;
border-radius: 8px;
padding: 20px;
}
.panelh2 {
margin-top: 0;
color: #232f3e;
}
.actions {
margin-bottom: 15px;
}
.actionsbutton {
margin-right: 10px;
padding: 8px16px;
background: #232f3e;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.actionsbutton:disabled {
background: #ccc;
cursor: not-allowed;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th,td {
padding: 10px;
text-align: left;
border-bottom: 1pxsolid#e0e0e0;
}
th {
background: #232f3e;
color: white;
}
.badge {
padding: 4px8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.badge.green { background: #d4edda; color: #155724; }
.badge.blue { background: #cce5ff; color: #004085; }
.badge.purple { background: #e2d5f1; color: #4a235a; }
.badge.gray { background: #e0e0e0; color: #333; }
.message {
padding: 10px15px;
margin-top: 15px;
border-radius: 4px;
}
.message.success { background: #d4edda; color: #155724; }
.message.error { background: #f8d7da; color: #721c24; }
</style>

File Upload Component

The upload component demonstrates the presigned URL pattern: the browser uploads directly to S3, bypassing our API server entirely. This reduces latency and server load.

Create Components/FileUpload.razor:

Upload UI

The user selects a target prefix (which determines which lifecycle rules will apply) and a file:

@usingS3LifecyclePolicies.Shared.Models
@injectHttpClientHttp
<divclass="upload-section">
<divclass="upload-controls">
<!-- Prefix dropdown - determines which lifecycle rules apply -->
<select@bind="selectedPrefix">
<optionvalue="logs/">logs/</option>
<optionvalue="temp/">temp/</option>
<optionvalue="archive/">archive/</option>
</select>
<InputFileOnChange="HandleFileSelected" />
<button@onclick="UploadFile"disabled="@(selectedFile==null||isUploading)">
@(isUploading?"Uploading...":"Upload")
</button>
</div>
@if (!string.IsNullOrEmpty(uploadMessage))
{
<pclass="upload-message @uploadMessageType">@uploadMessage</p>
}
</div>

Two-Step Upload Flow

The upload logic follows a two-step process:

  1. Request a presigned URL from our API (tells S3 we’re authorized to upload)
  2. PUT the file directly to S3 using that URL (browser → S3, no middleman)
@code{
// EventCallback allows parent components to react when upload completes
[Parameter]
publicEventCallbackOnUploadComplete { get; set; }
privatestringselectedPrefix="logs/";
privateIBrowserFile? selectedFile;
privateboolisUploading=false;
privatestringuploadMessage="";
privatestringuploadMessageType="";
privatevoidHandleFileSelected(InputFileChangeEventArgse)
{
selectedFile=e.File;
uploadMessage=""; // Clear any previous messages
}
privateasyncTaskUploadFile()
{
if (selectedFile==null) return;
isUploading=true;
uploadMessage="";
try
{
// Step 1: Get a presigned URL from our API
varrequest=newUploadUrlRequest(
selectedFile.Name,
selectedPrefix,
selectedFile.ContentType
);
varurlResponse=awaitHttp.PostAsJsonAsync("files/upload-url", request);
varuploadUrl=awaiturlResponse.Content.ReadFromJsonAsync<UploadUrlResponse>();
if (uploadUrl==null)
{
thrownewException("Failed to get upload URL");
}
// Step 2: Upload directly to S3 using the presigned URL
// Note: We create a NEW HttpClient here because the presigned URL
// is a completely different host (S3) than our API
usingvarcontent=newStreamContent(selectedFile.OpenReadStream(maxAllowedSize: 10*1024*1024));
content.Headers.ContentType=newSystem.Net.Http.Headers.MediaTypeHeaderValue(selectedFile.ContentType);
usingvarhttpClient=newHttpClient(); // Fresh client for S3
varresponse=awaithttpClient.PutAsync(uploadUrl.Url, content);
if (response.IsSuccessStatusCode)
{
uploadMessage=$"File uploaded successfully to {uploadUrl.Key}";
uploadMessageType="success";
selectedFile=null;
awaitOnUploadComplete.InvokeAsync(); // Notify parent to refresh file list
}
else
{
thrownewException($"Upload failed: {response.StatusCode}");
}
}
catch (Exceptionex)
{
uploadMessage=$"Error: {ex.Message}";
uploadMessageType="error";
}
finally
{
isUploading=false;
}
}
}

Why a separate HttpClient? The injected HttpClient has a base address pointing to our API. Presigned URLs are full URLs pointing to S3, so we need a fresh HttpClient without a base address to call them directly.

The 10MB limit (maxAllowedSize) is a Blazor safety guard - adjust as needed for your use case.

<style>
.upload-section {
background: white;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.upload-controls {
display: flex;
gap: 10px;
align-items: center;
}
.upload-controlsselect {
padding: 8px;
border: 1pxsolid#ccc;
border-radius: 4px;
}
.upload-controlsbutton {
padding: 8px16px;
background: #ff9900; /* AWS orange for action buttons */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.upload-controlsbutton:disabled {
background: #ccc;
}
.upload-message {
margin-top: 10px;
padding: 8px;
border-radius: 4px;
}
.upload-message.success { background: #d4edda; color: #155724; }
.upload-message.error { background: #f8d7da; color: #721c24; }
</style>

Lifecycle Rules Component

This component lets users view existing lifecycle rules and create new ones from our predefined templates.

Create Components/LifecycleRules.razor:

Template Selection UI

Users select from a dropdown of human-readable template names. We iterate over the enum values to populate the options:

@usingS3LifecyclePolicies.Shared.Models
@injectHttpClientHttp
<divclass="lifecycle-section">
<divclass="create-rule">
<select@bind="selectedTemplate">
<optionvalue="">-- Select Template --</option>
@foreach (vartemplateinEnum.GetValues<LifecycleTemplate>())
{
<optionvalue="@template">@FormatTemplateName(template)</option>
}
</select>
<button@onclick="CreateRule"disabled="@(string.IsNullOrEmpty(selectedTemplate) ||isLoading)">
Create Rule
</button>
</div>

Rules Table

The table displays all configured rules with their key properties - prefix, transitions, and expiration:

@if (rules==null)
{
<p>Loading rules...</p>
}
elseif (rules.Count==0)
{
<p>No lifecycle rules configured. Select a template above to create one.</p>
}
else
{
<table>
<thead>
<tr>
<th>Rule ID</th>
<th>Prefix</th>
<th>Transitions</th>
<th>Expiration</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (varruleinrules)
{
<tr>
<td>@rule.Id</td>
<td>@(rule.Prefix??"All objects")</td>
<td>
<!--Formattransitionsasavisualpipeline-->
@if (rule.Transitions.Count>0)
{
@string.Join(" → ", rule.Transitions.Select(t=>$"{t.StorageClass} ({t.Days}d)"))
}
else
{
<span>-</span>
}
</td>
<td>@(rule.ExpirationDays.HasValue?$"{rule.ExpirationDays} days":"-")</td>
<td>
<buttonclass="delete-btn"@onclick="()=> DeleteRule(rule.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>

Component Logic

The @code section handles loading, creating, and deleting rules:

@code{
// Notify parent when rules change (so file list can update transition predictions)
[Parameter]
publicEventCallbackOnRulesChanged { get; set; }
privateList<LifecycleRuleDto>? rules;
privatestringselectedTemplate="";
privateboolisLoading=false;
protectedoverrideasyncTaskOnInitializedAsync()
{
awaitLoadRules();
}
privateasyncTaskLoadRules()
{
isLoading=true;
try
{
rules=awaitHttp.GetFromJsonAsync<List<LifecycleRuleDto>>("lifecycle/rules");
}
finally
{
isLoading=false;
}
}
privateasyncTaskCreateRule()
{
if (string.IsNullOrEmpty(selectedTemplate)) return;
isLoading=true;
try
{
// Parse the string value back to enum
vartemplate=Enum.Parse<LifecycleTemplate>(selectedTemplate);
// Wrap in request object (required for JSON body binding)
varrequest=newCreateLifecycleRuleRequest(template);
awaitHttp.PostAsJsonAsync("lifecycle/rules", request);
selectedTemplate=""; // Reset selection
awaitLoadRules(); // Refresh the list
awaitOnRulesChanged.InvokeAsync(); // Notify parent
}
finally
{
isLoading=false;
}
}
privateasyncTaskDeleteRule(stringruleId)
{
isLoading=true;
try
{
awaitHttp.DeleteAsync($"lifecycle/rules/{ruleId}");
awaitLoadRules();
awaitOnRulesChanged.InvokeAsync();
}
finally
{
isLoading=false;
}
}
// Convert enum values to human-readable descriptions
privatestaticstringFormatTemplateName(LifecycleTemplatetemplate) =>templateswitch
{
LifecycleTemplate.ArchiveLogsAfter30Days=>"Archive Logs (30d → IA → Glacier → Delete)",
LifecycleTemplate.DeleteTempFilesAfter7Days=>"Delete Temp Files (7 days)",
LifecycleTemplate.MoveToGlacierAfter90Days=>"Move to Glacier (90 days)",
LifecycleTemplate.CleanupIncompleteUploads=>"Cleanup Incomplete Uploads (7 days)",
_=>template.ToString()
};
}

The OnRulesChanged callback is important: when a rule is created or deleted, the file list’s “Next Transition” predictions need to update. The parent component subscribes to this callback and refreshes the file list accordingly.

<style>
.lifecycle-section {
margin-top: 10px;
}
.create-rule {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.create-ruleselect {
flex: 1;
padding: 8px;
border: 1pxsolid#ccc;
border-radius: 4px;
}
.create-rulebutton {
padding: 8px16px;
background: #28a745; /* Green for create action */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.create-rulebutton:disabled {
background: #ccc;
}
table {
width: 100%;
border-collapse: collapse;
}
th,td {
padding: 10px;
text-align: left;
border-bottom: 1pxsolid#e0e0e0;
}
th {
background: #232f3e;
color: white;
}
.delete-btn {
padding: 4px12px;
background: #dc3545; /* Red for delete action */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>

Testing the Implementation

Let’s verify everything works. If you haven’t already, clone the complete solution from GitHub:

1. Create an S3 Bucket

Before running the application, create an S3 bucket in your AWS account:

Terminal window
awss3mbs3://your-lifecycle-demo-bucket--regionus-east-1

Update appsettings.json with your bucket name.

2. Run the API

Terminal window
cdS3LifecyclePolicies.Api
dotnetrun

The API will be available at http://localhost:5000. Visit http://localhost:5000/scalar/v1 for the interactive API documentation.

3. Run the Blazor Client

In a separate terminal:

Terminal window
cdS3LifecyclePolicies.Client
dotnetrun

4. Test the Workflow

  1. Open the Blazor client in your browser
  2. Click “Seed Demo Data” to create sample files
  3. Observe the files list with storage classes (all STANDARD initially)
  4. Create the “Archive Logs” lifecycle rule
  5. Notice the “Next Transition” column now shows estimated dates
  6. Upload a new file to the temp/ prefix
  7. Create the “Delete Temp Files” rule
  8. Check the AWS Console to verify rules are applied

👁 Blazor client showing files with storage class and transition info

👁 Lifecycle rules panel with template selection

Monitoring Lifecycle Execution

Lifecycle rules are evaluated once daily at midnight UTC. You won’t see immediate transitions. Here’s how to verify rules are working:

AWS Console

Navigate to your bucket → Management tab to see all configured rules.

S3 Storage Lens

For larger buckets, enable S3 Storage Lens to get analytics on:

  • Storage by class over time
  • Objects transitioned per day
  • Cost optimization recommendations

CloudWatch Metrics

S3 publishes metrics for:

  • NumberOfObjects by storage class
  • BucketSizeBytes by storage class

Create a CloudWatch dashboard to track transitions over time.

Best Practices

Use specific prefixes: Target lifecycle rules to specific folders (logs/, temp/, archive/). Avoid bucket-wide rules unless intentional.

Account for minimum storage duration: Glacier has a 90-day minimum. If you might need to delete objects sooner, consider Standard-IA (30-day minimum) first.

Test on non-production buckets: Always test lifecycle rules on a test bucket before applying to production.

Consider Intelligent-Tiering: For data with unpredictable access patterns, Intelligent-Tiering automatically moves objects between access tiers with no retrieval fees.

Clean up incomplete multipart uploads: The “Cleanup Incomplete Uploads” template prevents storage waste from abandoned uploads.

Document your rules: Use descriptive rule IDs that explain what each rule does.

Wrap-up

In this article, we built a complete S3 lifecycle management system:

  • Understood lifecycle policies: Rules, filters, transitions, and expiration
  • Compared storage classes: From Standard to Glacier Deep Archive
  • Created rules via AWS Console: Step-by-step walkthrough
  • Built a .NET API: Programmatic lifecycle management with templates
  • Built a Blazor WASM client: Upload files, view storage classes, manage rules

Lifecycle policies are a “set it and forget it” solution for storage cost optimization. Once configured, AWS handles everything automatically - transitioning objects to cheaper storage and deleting them when they expire.

The predefined templates we implemented cover the most common scenarios, but the AWS SDK supports far more complex configurations including tag-based filtering, multiple transitions, and version-specific rules. Lifecycle policies pair especially well with S3 versioning, where a NoncurrentVersionExpiration rule keeps old versions from quietly inflating your bill.

Read nextCompanion article

S3 Versioning in .NET

Keep every version of your objects, then use lifecycle rules to expire the noncurrent ones.

Read nextCompanion article

Serverless Image Processing with S3, SQS and Lambda

Process objects automatically as they land, before lifecycle rules transition them to cold storage.

Read nextCompanion article

LocalStack for .NET Teams

Test lifecycle rules against a local S3 before pointing them at a real bucket.

Grab the complete source code from GitHub, and start automating your S3 storage management today.

What lifecycle patterns are you implementing? Let me know in the comments below.

Happy Coding :)

More from the archive.

View all articles

What's your take?

Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.

View on GitHub

Weekly .NET tips · free

Subscribed · Tue 7 PM IST

You're in.
Welcome to the crew.

Tuesday's issue lands in your inbox at 7 PM IST. One last step: confirm your email so it actually arrives.

01 · Check your inbox

02 · Every Tuesday

Benchmarks, architecture insights, and production tips that never make it to the blog.

Privacy notice · 30s read

Cookies, but only the useful ones.

I use cookies to understand which articles get read and which CTAs actually work. No third-party advertising trackers, ever. Read the privacy policy →