1. Introduction: .NET Has a Home on AWS
There's a perception that AWS is "not for .NET," that it's a Python and Node.js world, and C# developers are second-class citizens. That hasn't been true for a while, and in 2026 it's less true than ever.
AWS has invested heavily in .NET tooling: Lambda Annotations for clean function authoring, CDK support for infrastructure-as-code in C#, Graviton/ARM support for cost-efficient compute, and Native AOT for sub-100ms cold starts. The developer experience has gotten good.
But the journey from "we have a .NET app on Windows Server" to "we're running serverless on Lambda with automated infrastructure" isn't one step. It's a spectrum, and where you land depends on your constraints, your team, and how much change you can absorb at once.
The Spectrum
Lift-and-shift β Modernize runtime β Rewrite for cloud-native. Each step delivers value. You don't have to go all the way to Lambda on day one.
This guide walks through each stage of that spectrum, then covers the tooling that makes the cloud-native end productive: Lambda Annotations, CDK, and open source libraries I've built to fill the gaps in the AWS .NET ecosystem.
2. Lift and Shift: Get to AWS First
The fastest path to AWS is moving what you have. If you're running .NET Framework on Windows Server, you can run that same application on EC2 Windows instances or Elastic Beanstalk with minimal code changes.
EC2: The Direct Translation
EC2 Windows instances are the closest analog to on-premises Windows Server. You get a Windows VM, install IIS, deploy your application, and it runs. The application doesn't know it moved.
When EC2 makes sense
- β’ .NET Framework 4.x applications that can't easily migrate to modern .NET
- β’ Applications with Windows-specific dependencies (COM, WCF, Windows Auth)
- β’ Teams that need to move quickly and optimize later
- β’ Workloads with predictable, sustained traffic patterns
Watch the licensing
Windows EC2 instances include the Windows Server license in the hourly rate, which adds roughly 30-40% compared to equivalent Linux instances. This cost delta is one of the strongest motivations for eventually moving to modern .NET on Linux.
Elastic Beanstalk: Managed Deployment
Beanstalk wraps EC2 with deployment automation, auto-scaling, load balancing, and health monitoring. For teams that want to get out of the business of managing Windows Server patches and IIS configuration, it's a reasonable middle ground.
You deploy a zip file or a Docker container, Beanstalk handles the rest. It supports both .NET Framework on Windows and modern .NET on Linux, so it can also serve as a stepping stone to the next phase.
Beanstalk trade-offs
Pros
- β’ Managed patching and updates
- β’ Built-in auto-scaling and load balancing
- β’ Blue/green deployments out of the box
- β’ No additional cost beyond the underlying resources
Cons
- β’ Less control than raw EC2 or ECS
- β’ Platform updates can be disruptive
- β’ Not ideal for microservices architectures
- β’ Debugging deployment issues can be opaque
The Lift-and-Shift Reality Check
Lift-and-shift gets you to AWS, but it doesn't get you cloud-native benefits. You're still paying for idle compute, still managing servers (or at least server configurations), and still deploying monoliths. That's fine as a starting point. The value is in getting off on-premises hardware, into a region closer to your users, and onto infrastructure you can iterate on.
The key is to treat it as phase one, not the destination.
3. Modernize the Runtime: Modern .NET, Still Lift-and-Shift
The single highest-impact change you can make is migrating from .NET Framework to modern .NET (8 or 9). Even if you keep the same deployment model (EC2 or Beanstalk), the benefits are substantial.
~40%
Faster throughput vs .NET Framework
~30%
Lower memory usage
~35%
Cost savings from Linux
Why Modern .NET Matters on AWS
Modern .NET runs on Linux. That single fact unlocks a cascade of benefits on AWS:
- No Windows license cost β Linux EC2 instances are 30-40% cheaper than equivalent Windows instances
- ARM/Graviton support β another 20% cost reduction with better performance (covered in section 5)
- Container-friendly β smaller base images, faster startup, better orchestration options
- Lambda-ready β modern .NET is a prerequisite for Lambda and Native AOT
- Better performance β modern .NET (10 as of early 2026) is much faster than .NET Framework in virtually every benchmark
The Migration Path
Microsoft's .NET Upgrade Assistant handles much of the mechanical work. The real challenges are:
Common migration blockers
- Windows-specific APIs β System.Drawing, Registry access, Windows Authentication. These need alternatives or compatibility shims.
- WCF services β No direct equivalent in modern .NET. CoreWCF covers some scenarios; others need a rewrite to gRPC or REST.
- Third-party libraries β Some older NuGet packages never updated. Check compatibility early.
- Global.asax / Web.config β ASP.NET Core uses a completely different startup and configuration model.
You don't need a big-bang migration. Tackle one project or service at a time: migrate it, validate it, deploy it, then move on to the next. Each migrated service starts saving you money immediately, and the team builds muscle memory with each one. The first service is the hardest; by the third or fourth, it's routine.
The payoff
Even without changing your deployment model, migrating to modern .NET on Linux can cut your compute costs by 50% or more (Windows license savings + right-sizing from better performance). It also opens the door to everything in the next sections.
4. Rewrite for Cloud-Native: Containers and Lambda
Once you're on modern .NET, you can target Linux containers on ECS/Fargate or go fully serverless with Lambda. This is where the architecture changes, not just the runtime.
Linux Containers on ECS/Fargate
Containers are the natural next step for teams that want cloud-native benefits without going fully serverless. You get consistent environments, fast deployments, and fine-grained scaling without managing EC2 instances directly.
ECS Fargate for .NET
- β’ No EC2 instances to manage β Fargate handles the compute
- β’ Scale to zero with ECS Service auto-scaling (though not instant like Lambda)
- β’ Good for long-running processes, WebSocket connections, background workers
- β’ .NET's minimal API or traditional ASP.NET Core both work well
- β’ Graviton (ARM) Fargate tasks available for additional cost savings
AWS Lambda: Fully Serverless
Lambda is where .NET on AWS gets interesting. You write functions, AWS handles everything else: scaling, patching, availability. You pay only for the milliseconds your code runs.
The historical knock on .NET Lambda was cold starts. With Native AOT (section 6), that's largely solved. A well-optimized .NET Lambda function starts in under 100ms, competitive with Node.js and Python.
Lambda shines for
- β’ API endpoints behind API Gateway
- β’ Event-driven processing (SQS, EventBridge, S3)
- β’ Scheduled tasks and cron jobs
- β’ Microservices with variable traffic
- β’ Workloads that can complete in under 15 minutes
Consider containers instead for
- β’ Long-running processes (>15 min)
- β’ WebSocket or persistent connections
- β’ High-throughput, sustained workloads
- β’ Applications needing >10GB memory
- β’ Workloads with large deployment packages
In practice, most modern .NET applications on AWS use a mix: Lambda for API endpoints and event handlers, ECS for background workers and long-running processes. The two complement each other well.
5. x86 vs ARM/Graviton
AWS Graviton processors are ARM-based chips designed by Amazon. They offer better price-performance than equivalent x86 instances, typically 20% cheaper with equal or better throughput for most .NET workloads.
Graviton (ARM64)
- β’ ~20% cheaper than equivalent x86
- β’ Better energy efficiency
- β’ Excellent modern .NET support (.NET 8, 9, 10)
- β’ Available for EC2, Fargate, Lambda, and RDS
- β’ Most NuGet packages work without changes
x86 (Intel/AMD)
- β’ Universal compatibility
- β’ Required for some native dependencies
- β’ Broader instance type selection
- β’ Necessary for .NET Framework
- β’ Some specialized workloads perform better
Making the Switch
For pure .NET code (no native dependencies), switching to Graviton is usually as simple as changing the architecture target. .NET's runtime handles the ARM compilation transparently.
Lambda β switch to ARM
// In your CDK stack or SAM template
Architecture = Lambda.Architecture.ARM_64
// Or in serverless.template
"Architectures": ["arm64"]
The main gotcha is native dependencies: NuGet packages that include platform-specific binaries (like SQLite, image processing libraries, or gRPC). These need ARM-compatible versions. Most popular packages support ARM64 now, but check before deploying.
Quick win
If you're running Lambda functions on x86, switching to arm64 is often a one-line change that saves 20% on compute costs with no code changes. Test it in a dev environment first, but for most .NET workloads it just works.
6. Native AOT: Solving the Cold Start Problem
Native Ahead-of-Time (AOT) compilation is the biggest quality-of-life improvement for .NET on Lambda. Instead of shipping IL code that the JIT compiles at runtime, you ship a pre-compiled native binary. The result: cold starts drop from 1-3 seconds to under 100ms.
JIT (Traditional)
1-3s
Cold start
Native AOT
<100ms
Cold start
How It Works
Native AOT compiles your .NET code to a self-contained native binary at build time. No JIT, no runtime compilation, no reflection-based startup overhead. The binary starts like a Go or Rust program. Fast.
Enable Native AOT in your .csproj
<PropertyGroup>
<PublishAot>true</PublishAot>
<StripSymbols>true</StripSymbols>
</PropertyGroup>
The Trade-offs
Native AOT isn't free. The compilation model imposes constraints:
AOT constraints
- No runtime reflection β You can't use
typeof(T).GetProperties()or dynamic type loading. This affects some serialization libraries and DI containers. - Source generators required β JSON serialization needs
System.Text.Jsonsource generators instead of reflection-based serializers. - Trimming warnings β The linker aggressively removes unused code. Libraries that use reflection may break unless they're trim-compatible.
- Cross-compilation β You must build on the target platform (Linux ARM for Lambda ARM). Docker or CI handles this.
- Useless stack traces β AOT-compiled binaries produce stack traces with mangled or missing method names, making debugging harder. This has been a known issue for years, with a fix currently targeting .NET 11. The irony: your dev environment is where you need stack traces most, and it's also where you hit cold starts most often, which is the whole reason you're using AOT.
In practice, if you're building new Lambda functions with modern patterns (source-generated JSON, constructor-based DI), AOT works smoothly. Retrofitting older code that relies heavily on reflection takes more effort. The stack trace issue is the most frustrating day-to-day trade-off. When something goes wrong in a dev environment, you're often left reading logs and reasoning about the failure rather than jumping straight to the line that threw.
My recommendation
For new Lambda projects, start with AOT from day one. The constraints push you toward better patterns anyway (explicit serialization, no reflection magic). For existing projects, evaluate whether the cold start improvement justifies the migration effort.
7. Lambda Annotations: Writing Lambda Functions Like Normal C#
Lambda Annotations is an AWS-provided framework that lets you write Lambda functions using familiar ASP.NET-style patterns (attribute-based routing, dependency injection, model binding) instead of manually parsing APIGatewayProxyRequest objects.
It's a source generator. At build time, it generates the boilerplate Lambda handler code for you. You write clean C# methods; it handles the plumbing.
Before and After
Without Annotations β manual request parsing
public async Task<APIGatewayProxyResponse> GetCustomer(
APIGatewayProxyRequest request, ILambdaContext context)
{
var id = request.PathParameters["id"];
var customer = await _service.GetAsync(id);
return new APIGatewayProxyResponse
{
StatusCode = 200,
Body = JsonSerializer.Serialize(customer),
Headers = new Dictionary<string, string>
{
{ "Content-Type", "application/json" }
}
};
}
With Annotations β clean, familiar C#
[LambdaFunction]
[RestApi(LambdaHttpMethod.Get, "/customers/{id}")]
public async Task<IHttpResult> GetCustomer(
[FromServices] ICustomerService service,
string id)
{
var customer = await service.GetAsync(id);
return HttpResults.Ok(customer);
}
The difference is dramatic. The Annotations version reads like a normal ASP.NET controller method. Route parameters are extracted automatically, DI is handled via [FromServices], and response serialization is built in.
Key Features
- Attribute-based routing β
[RestApi]with HTTP method and path template, just like ASP.NET - Dependency injection β Full DI via a
Startupclass withConfigureServices, resolved with[FromServices] - Model binding β
[FromBody],[FromQuery],[FromHeader], and path parameters - Source-generated β No reflection, fully AOT-compatible
- CloudFormation output β Automatically generates the SAM/CloudFormation template for your functions
Project Structure
A Lambda Annotations project is a standard .NET console application. The source generator creates the entry point and handler wiring. You organize your code however makes sense. I typically use a structure like this:
MyApi/
βββ Functions/
β βββ CustomerFunctions.cs # Lambda function definitions
β βββ OrderFunctions.cs
βββ Services/
β βββ ICustomerService.cs # Business logic interfaces
β βββ CustomerService.cs
βββ Models/
β βββ CreateCustomerRequest.cs # Request/response DTOs
βββ Validators/
β βββ CreateCustomerValidator.cs
βββ Startup.cs # DI registration
Each method decorated with [LambdaFunction] becomes its own Lambda function in AWS. You can have multiple functions in a single project. They share the same DI container and deployment package, but scale independently.
Why this matters
Lambda Annotations removes the biggest friction point in .NET Lambda development: the boilerplate. You stop fighting with APIGatewayProxyRequest and start writing business logic. Combined with AOT, you get fast, clean, maintainable serverless code.
8. CDK with C#: Infrastructure as Actual Code
AWS CDK (Cloud Development Kit) lets you define cloud infrastructure using real programming languages instead of YAML or JSON templates. And yes, C# is a first-class CDK language.
This means your infrastructure code lives alongside your application code, in the same language, with the same IDE support: IntelliSense, refactoring, type checking, unit tests. No more context-switching between C# and CloudFormation YAML.
What CDK Looks Like
Define a DynamoDB table + Lambda function in C#
var table = new Table(this, "CustomersTable", new TableProps
{
PartitionKey = new Attribute
{
Name = "pk",
Type = AttributeType.STRING
},
BillingMode = BillingMode.PAY_PER_REQUEST,
RemovalPolicy = RemovalPolicy.RETAIN
});
var function = new Function(this, "GetCustomerFunction", new FunctionProps
{
Runtime = Runtime.DOTNET_10,
Architecture = Architecture.ARM_64,
Handler = "MyApi::MyApi.Functions_GetCustomer_Generated::GetCustomer",
Code = Code.FromAsset("./src/MyApi/bin/Release/net10.0/publish"),
MemorySize = 256,
Timeout = Duration.Seconds(30),
Environment = new Dictionary<string, string>
{
["TABLE_NAME"] = table.TableName
}
});
table.GrantReadData(function);
That last line β table.GrantReadData(function) β is where CDK shines. It automatically creates the IAM policy with the minimum permissions needed. No hand-crafting IAM JSON, no forgetting to add permissions, no overly broad dynamodb:* policies.
Why C# for CDK
- One language for everything β Application code, infrastructure, and tests all in C#. One toolchain, one CI/CD pipeline.
- Type safety β The compiler catches misconfigured resources before deployment. No more discovering typos in CloudFormation at deploy time.
- Abstractions β Build reusable constructs (L3 patterns) that encode your organization's standards. A
new StandardLambdaFunction()can include your default memory, timeout, tracing, and alarm configuration. - Testing β Write unit tests for your infrastructure. Assert that your Lambda function has the right permissions, your DynamoDB table has the right key schema, your API Gateway has the right routes.
A note on TypeScript
Most CDK examples online are in TypeScript. The C# API is identical in structure β the constructs and props are the same, just with C# naming conventions. If you find a TypeScript CDK example, translating it to C# is straightforward.
9. FluentDynamoDB: DynamoDB Without the Pain
DynamoDB is the natural database choice for serverless .NET on AWS. It scales automatically, has single-digit millisecond latency, and costs nothing when idle. But the official AWS SDK for DynamoDB in .NET is... verbose. Very verbose.
FluentDynamoDB is an open source library I built to fix that. It's a source-generated, AOT-compatible framework that gives you a fluent, type-safe API for DynamoDB operations β without reflection, without the Document Model's runtime overhead, and without writing raw Dictionary<string, AttributeValue> everywhere.
The Problem It Solves
Raw AWS SDK β updating a single field
var request = new UpdateItemRequest
{
TableName = "Customers",
Key = new Dictionary<string, AttributeValue>
{
["pk"] = new AttributeValue { S = "CUSTOMER#123" },
["sk"] = new AttributeValue { S = "PROFILE" }
},
UpdateExpression = "SET #name = :name, #ud = :ud",
ExpressionAttributeNames = new Dictionary<string, string>
{
["#name"] = "name",
["#ud"] = "updatedDate"
},
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
[":name"] = new AttributeValue { S = "New Name" },
[":ud"] = new AttributeValue { S = DateTime.UtcNow.ToString("O") }
},
ConditionExpression = "attribute_exists(pk)"
};
await client.UpdateItemAsync(request);
FluentDynamoDB β same operation
await table.Customers.Update(Customer.Keys.Pk("123"), Customer.Keys.Sk("PROFILE"))
.IfExists()
.Set(x => new CustomerUpdateModel
{
Name = "New Name",
UpdatedDate = DateTime.UtcNow
})
.UpdateAsync();
Same result, a fraction of the code. And the FluentDynamoDB version is type-safe β the compiler catches typos in property names, and the source generator handles all the AttributeValue mapping.
Key Features
Developer Experience
- β’ Lambda expressions for queries and updates
- β’ Type-safe key handling with generated
Keysclass - β’ Fluent builder pattern for all operations
- β’ Conditional expressions with C# syntax
- β’ Batch and transaction support
Production-Ready
- β’ Fully AOT-compatible (source-generated)
- β’ No reflection at runtime
- β’ Optimistic locking patterns built in
- β’ Pagination with encoded tokens
- β’ FluentResults integration for error handling
Entity Definition
You define entities with attributes, and the source generator creates the mapping code, key builders, and table accessors:
[DynamoDbTable("customers")]
public partial class Customer
{
[PartitionKey(Prefix = "CUSTOMER")]
[DynamoDbAttribute("pk")]
public string Pk { get; set; } = string.Empty;
[SortKey(Prefix = "PROFILE")]
[DynamoDbAttribute("sk")]
public string Sk { get; set; } = string.Empty;
[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;
[DynamoDbAttribute("email")]
public string Email { get; set; } = string.Empty;
[GlobalSecondaryIndex("email-index", IsPartitionKey = true)]
[DynamoDbAttribute("gsi1pk")]
public string EmailIndex { get; set; } = string.Empty;
}
From that definition, FluentDynamoDB generates:
- β’
Customer.Keys.Pk("123")β"CUSTOMER#123" - β’ Type-safe query builders for the table and GSI
- β’
CustomerUpdateModelfor partial updates - β’ Full
ToDynamoDb()/FromDynamoDb()mapping β no reflection
Querying
// Query with lambda expressions
var customers = await table.Customers
.Query(x => x.Pk == Customer.Keys.Pk(tenantId)
&& x.Sk.StartsWith("PROFILE"))
.WithFilter(x => x.Status == "active")
.Take(25)
.ToListAsync();
// Pagination
var token = query.Response?.GetEncodedPaginationToken();
var nextPage = await table.Customers
.Query(x => x.Pk == Customer.Keys.Pk(tenantId))
.Paginate(new PaginationRequest(25, token))
.ToListAsync();
The library also supports composite entities (multi-item patterns), projections, dynamic fields for sparse attribute patterns, batch operations, transactions, and PartiQL, all with the same fluent, type-safe API.
Open source
FluentDynamoDB is available on fluentdynamodb.dev with full documentation and examples. It's what I use in production for every DynamoDB-backed service I build.
10. LambdaOpenApi: API Documentation from Your Code
If you're using Lambda Annotations, your functions already have route definitions, request/response types, and HTTP method declarations. LambdaOpenApi extracts that information at build time and generates an OpenAPI 3.0 specification. No manual spec maintenance, no Swagger annotations, no drift between your code and your docs.
The Problem
In a traditional ASP.NET Core application, Swashbuckle or NSwag generates your OpenAPI spec at runtime by reflecting over your controllers. Lambda functions don't have a running web server to reflect against. So .NET Lambda developers typically maintain OpenAPI specs by hand β which means they're always out of date.
How LambdaOpenApi Works
LambdaOpenApi is a build-time tool. It analyzes your Lambda Annotations project β the [RestApi] attributes, the method signatures, the request/response types β and generates a complete OpenAPI 3.0 document.
Annotate your functions
[LambdaFunction]
[RestApi(LambdaHttpMethod.Post, "/customers")]
[OpenApiOperation("CreateCustomer", "Creates a new customer")]
[OpenApiRequestBody(typeof(CreateCustomerRequest))]
[OpenApiResponse(201, typeof(Customer), "Customer created")]
[OpenApiResponse(400, typeof(ErrorResponse), "Validation error")]
public async Task<IHttpResult> CreateCustomer(
[FromServices] ICustomerService service,
[FromBody] CreateCustomerRequest request)
{
// ...
}
Build, and get a spec
dotnet build
# Outputs: openapi.json in your project directory
Why This Matters at Scale
When you have dozens of Lambda functions across multiple microservices, each generating its own OpenAPI spec, you can merge them into a unified API document at build time. This feeds into:
- SDK generation β Use the merged spec to auto-generate typed client SDKs for your API consumers
- API Gateway configuration β Import the spec directly into API Gateway for route configuration
- Documentation portals β Feed the spec into Redoc, Swagger UI, or your own docs site
- Contract testing β Validate that your implementation matches the spec in CI
The spec is always accurate because it's generated from the code. No manual sync, no forgotten updates, no "the docs say one thing but the API does another."
Open source
LambdaOpenApi is available at lambdaopenapi.dev. It pairs naturally with Lambda Annotations β if you're using one, the other is a low-effort addition that pays dividends as your API surface grows.
11. Putting It All Together
Here's what a modern .NET on AWS stack looks like when you combine everything in this guide:
The Modern .NET on AWS Stack
AWS CDK in C#
Infrastructure defined in the same language as your application. Type-safe, testable, reusable constructs.
Lambda with Annotations + Native AOT on ARM
Clean function authoring, sub-100ms cold starts, 20% cheaper on Graviton.
DynamoDB with FluentDynamoDB
Serverless database with a type-safe, fluent API. No reflection, full AOT support.
LambdaOpenApi for API documentation
OpenAPI specs generated from code at build time. Always accurate, feeds SDK generation.
The Migration Roadmap
You don't need to adopt everything at once. Here's a practical sequence:
Phase 1: Get to AWS
Lift-and-shift to EC2 or Beanstalk. Minimal code changes. Get off on-premises hardware.
Phase 2: Modernize the runtime
Migrate from .NET Framework to modern .NET. Switch to Linux. Immediate cost savings from dropping Windows licensing.
Phase 3: Switch to ARM
Move to Graviton instances or ARM Lambda. Often a one-line change for 20% savings.
Phase 4: Go serverless
Rewrite to Lambda with Annotations. Enable Native AOT. Adopt FluentDynamoDB for data access.
Phase 5: Automate everything
CDK for infrastructure. LambdaOpenApi for documentation. CI/CD pipelines that build, test, and deploy the whole stack.
Each phase delivers standalone value. You can stop at any point and still be better off than where you started. The key is forward momentum. Don't let perfect be the enemy of progress.
The bottom line
.NET on AWS in 2026 is a good developer experience. The tooling has matured, the performance is excellent, and the ecosystem gaps that used to exist have been filled by AWS, by Microsoft, and by the open source community. If you're a .NET shop considering AWS, the path is clearer than it's ever been.