When to Use Aurora/RDS Instead of DynamoDB
DynamoDB is great for known access patterns with massive scale. But plenty of workloads are better served by a relational database:
- Complex queries with joins, aggregations, and ad-hoc reporting
- Small datasets where DynamoDB's per-request pricing is overkill
- Existing applications with EF Core that you're migrating to AWS
- ACID transactions spanning multiple entity types
- Full-text search (PostgreSQL)
- Geospatial queries (PostGIS)
Aurora is AWS's drop-in replacement for MySQL and PostgreSQL. Same drivers, same SQL, better performance, and managed by AWS. Standard RDS gives you the traditional engines (SQL Server, MySQL, PostgreSQL, MariaDB) with less overhead than self-managing an EC2 instance.
EF Core with Aurora PostgreSQL
Connection string
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
{
"ConnectionStrings": {
"DefaultConnection": "Host=my-cluster.cluster-abc123.us-east-1.rds.amazonaws.com;Database=myapp;Username=app_user;Password=xxx;SSL Mode=Require;Trust Server Certificate=true"
}
}
DbContext setup
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.CustomerId);
entity.HasMany(e => e.Items).WithOne(e => e.Order).HasForeignKey(e => e.OrderId);
});
}
}
RDS Proxy for Lambda
Lambda's concurrency model creates a problem for relational databases: each Lambda instance opens its own database connection. At 100 concurrent invocations, you have 100 connections. At 1000, you hit the database's max connections limit.
RDS Proxy sits between Lambda and your database, pooling and reusing connections:
Lambda (many instances) β RDS Proxy (connection pool) β Aurora (limited connections)
Connection string through RDS Proxy
// Same connection string format β just point to the proxy endpoint
"Host=my-proxy.proxy-abc123.us-east-1.rds.amazonaws.com;Database=myapp;Username=app_user;Password=xxx;SSL Mode=Require"
Your code doesn't change. The proxy handles connection pooling transparently.
IAM Authentication
Instead of storing database passwords in environment variables or Secrets Manager, use IAM auth. The Lambda's execution role authenticates directly:
using Amazon.RDS.Util;
public class IamAuthDbContextFactory
{
private readonly string _hostname;
private readonly int _port;
private readonly string _dbUser;
private readonly string _region;
public async Task<string> GetConnectionStringAsync()
{
// Generate a short-lived auth token (valid 15 minutes)
var token = RDSAuthTokenGenerator.GenerateAuthToken(
_hostname, _port, _dbUser);
return $"Host={_hostname};Port={_port};Database=myapp;Username={_dbUser};Password={token};SSL Mode=Require;Trust Server Certificate=true";
}
}
The token is generated locally from the Lambda's IAM credentials. No network call to Secrets Manager needed.
Connection Management in Lambda
EF Core's default connection pooling doesn't work well in Lambda because each invocation gets a cold or warm container independently. Best practices:
public class Function
{
private static readonly AppDbContext _dbContext;
static Function()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(Environment.GetEnvironmentVariable("CONNECTION_STRING"), npgsqlOptions =>
{
npgsqlOptions.CommandTimeout(30);
// Keep connections alive across warm invocations
npgsqlOptions.EnableRetryOnFailure(3);
})
.Options;
_dbContext = new AppDbContext(options);
}
public async Task<APIGatewayProxyResponse> Handler(
APIGatewayProxyRequest request, ILambdaContext context)
{
var orders = await _dbContext.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.Take(20)
.ToListAsync();
// ...
}
}
Key: Initialize the DbContext in the static constructor so it persists across warm invocations. But always use RDS Proxy. Without it, you'll exhaust connections under load.
Aurora Serverless v2
For workloads with variable traffic, Aurora Serverless v2 scales capacity automatically (0.5 to 128 ACUs). You don't pick an instance size. It scales based on load:
// In CDK (see setup section below)
Good for:
- Dev/staging environments (scales to minimum when idle)
- SaaS applications with unpredictable tenant activity
- Applications with burst traffic patterns
CDK Setup (C#)
using Amazon.CDK;
using Amazon.CDK.AWS.RDS;
using Amazon.CDK.AWS.EC2;
// Aurora Serverless v2 cluster
var cluster = new DatabaseCluster(this, "AppDatabase", new DatabaseClusterProps
{
Engine = DatabaseClusterEngine.AuroraPostgres(new AuroraPostgresClusterEngineProps
{
Version = AuroraPostgresEngineVersion.VER_16_4,
}),
Vpc = vpc,
VpcSubnets = new SubnetSelection { SubnetType = SubnetType.PRIVATE_ISOLATED },
ServerlessV2MinCapacity = 0.5,
ServerlessV2MaxCapacity = 4,
Writer = ClusterInstance.ServerlessV2("writer"),
Readers = new[]
{
ClusterInstance.ServerlessV2("reader", new ServerlessV2ClusterInstanceProps
{
ScaleWithWriter = true,
}),
},
DefaultDatabaseName = "myapp",
DeletionProtection = true,
StorageEncrypted = true,
IamAuthentication = true,
});
// RDS Proxy
var proxy = cluster.AddProxy("AppProxy", new DatabaseProxyOptions
{
Vpc = vpc,
Secrets = new[] { cluster.Secret! },
IamAuth = true,
RequireTls = true,
});
// Allow Lambda to connect through proxy
proxy.GrantConnect(lambdaFunction, "app_user");
// Pass proxy endpoint to Lambda
lambdaFunction.AddEnvironment("DB_HOST", proxy.Endpoint);
Tips for .NET Developers
- Always use RDS Proxy with Lambda. Without it, you'll hit connection limits under any real concurrency. Proxy adds ~5ms latency but saves you from connection exhaustion.
- Use IAM auth over stored passwords when possible. One fewer secret to rotate, and the auth token auto-generates from the execution role.
- Aurora Serverless v2 scales to zero-ish (0.5 ACU minimum). For production workloads with steady traffic, provisioned instances are cheaper. Use Serverless for dev/test or bursty workloads.
- EF Core migrations on Lambda are awkward. Run migrations from a separate process (CodeBuild step, ECS task) rather than in the Lambda cold start path.
- Read replicas for read-heavy workloads. Aurora supports up to 15 read replicas. Use a separate connection string for read-only queries to offload the writer.
Further Reading
Related Blog Posts
Looking for hands-on help? View my .NET on AWS services β