Free .NET Web API Course

LocalStack for .NET Developers – Run AWS Services Locally for Free

Stop burning AWS credits during development. Learn how to run S3, DynamoDB, and SQS locally with LocalStack, wire them into your .NET applications with simple config switches, and integrate LocalStack into CI for cost-free integration tests.

aws dotnet

localstack s3 dynamodb sqs docker testing ci-cd

13 min read

Every time you run integration tests against AWS, you’re paying for it—DynamoDB reads, S3 operations, SQS messages. During active development, those costs add up fast. Worse, you need internet connectivity, and your tests depend on external service availability.

LocalStack solves this by emulating AWS services on your machine. You get S3, DynamoDB, SQS, SNS, Lambda, and 80+ other services running in a Docker container—completely free for the core services. Your .NET application connects to localhost:4566 instead of AWS, and you can develop offline, run tests without cloud costs, and iterate faster.

In this article, we’ll build a .NET 10 Minimal API that writes orders to DynamoDB, uploads receipts to S3, and publishes events to SQS. The same code will work against LocalStack (for development and CI) or real AWS (for staging and production)—controlled entirely by configuration. No code changes required.

The full sample code for this article lives at github.com/iammukeshm/localstack-for-dotnet-teams—clone it to follow along.

Thank You, AWS! 🚀

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

Sponsored Content

Why LocalStack for .NET Teams?

If you’ve built AWS-integrated .NET applications, you know the friction:

  • Cost: Every PutItem, SendMessage, or PutObject during development costs money. Run your test suite 50 times a day across a team, and you’re burning credits.
  • Speed: Network latency to AWS adds up. Local operations are instant.
  • Offline development: No internet? No problem with LocalStack.
  • CI costs: GitHub Actions or Azure DevOps running integration tests against real AWS? That’s expensive and slow.
  • Environment isolation: Create and destroy resources freely without affecting shared AWS accounts.

LocalStack gives you fast feedback loops: create buckets, tables, and queues locally, run integration tests without touching AWS, and work offline when needed. It shines in dev/test and CI. For final validation (VPC, TLS, IAM edge cases), you should still hit a real AWS sandbox before production.

What We’re Building

We’ll create a simple order processing API with three AWS integrations:

  1. DynamoDB – Store order records
  2. S3 – Upload order receipt files
  3. SQS – Publish order events for downstream processing

The architecture looks like this:

POST /orders
→ Save to DynamoDB (Orders table)
→ Upload receipt to S3 (orders-receipts bucket)
→ Send message to SQS (orders-events queue)
→ Return order confirmation

The key insight: your .NET code doesn’t care if it’s talking to LocalStack or AWS. The AWS SDK uses the same interfaces (IAmazonS3, IAmazonDynamoDB, IAmazonSQS). We just configure different endpoints.

Prerequisites

Here’s what you need:

  • Docker Desktop – LocalStack runs as a container
  • .NET 10 SDK – We’re using the latest .NET
  • Visual Studio 2026 or VS Code – Your IDE of choice
  • AWS CLI v2 – For creating and verifying LocalStack resources. Install AWS CLI v2 here

No AWS account required for this tutorial—that’s the point!

Step 1: Set Up LocalStack with Docker Compose

Create a new directory for your project and add a docker-compose.yml file:

services:
localstack:
image: localstack/localstack:latest
container_name: localstack
ports:
- "4566:4566"
environment:
- SERVICES=s3,dynamodb,sqs
- DEBUG=0
- PERSISTENCE=1
volumes:
- "./localstack-data:/var/lib/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"

Let’s break down what each setting does:

  • Port 4566: This is LocalStack’s “edge” port—all services are accessible through this single endpoint
  • SERVICES: We’re only running S3, DynamoDB, and SQS (faster startup, lower memory)
  • DEBUG=0: Keeps logs clean; set to 1 when troubleshooting
  • PERSISTENCE=1: Data survives container restarts (stored in ./localstack-data)

Start LocalStack:

Terminal window
docker compose up -d

Verify it’s running:

Terminal window
docker compose logs localstack

You should see:

localstack | Ready.

LocalStack container running in Docker Desktop

Configure AWS CLI for LocalStack

LocalStack doesn’t validate credentials, so you can use any dummy values. Set these environment variables:

Linux/macOS:

Terminal window
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1

Windows (PowerShell):

Terminal window
$env:AWS_ACCESS_KEY_ID="test"
$env:AWS_SECRET_ACCESS_KEY="test"
$env:AWS_DEFAULT_REGION="us-east-1"

To interact with LocalStack, use the standard AWS CLI with the --endpoint-url flag:

Terminal window
aws --endpoint-url=http://localhost:4566 s3 ls

Throughout this article, we’ll use --endpoint-url=http://localhost:4566 with all AWS CLI commands to target LocalStack instead of real AWS.

Step 2: Create AWS Resources in LocalStack

Before our .NET app can use these services, we need to create the resources. Run these commands:

Create the S3 Bucket

Terminal window
aws --endpoint-url=http://localhost:4566 s3 mb s3://orders-receipts

Verify:

Terminal window
aws --endpoint-url=http://localhost:4566 s3 ls

Output:

2025-12-26 10:00:00 orders-receipts

Create the DynamoDB Table

Terminal window
aws --endpoint-url=http://localhost:4566 dynamodb create-table --table-name Orders --attribute-definitions AttributeName=OrderId,AttributeType=S --key-schema AttributeName=OrderId,KeyType=HASH --billing-mode PAY_PER_REQUEST

Verify:

Terminal window
aws --endpoint-url=http://localhost:4566 dynamodb list-tables

Output:

{
"TableNames": [
"Orders"
]
}

Create the SQS Queue

Terminal window
aws --endpoint-url=http://localhost:4566 sqs create-queue --queue-name orders-events

Verify:

Terminal window
aws --endpoint-url=http://localhost:4566 sqs list-queues

Output:

{
"QueueUrls": [
"http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-events"
]
}

LocalStack resources created via awslocal CLI

Step 3: Create the .NET 10 Project

Create a new Minimal API project:

Terminal window
dotnet new webapi -n LocalStackDemo
cd LocalStackDemo

Add the required NuGet packages:

Terminal window
dotnet add package AWSSDK.S3
dotnet add package AWSSDK.DynamoDBv2
dotnet add package AWSSDK.SQS
dotnet add package Scalar.AspNetCore

Step 4: Configure Endpoint Switching

The magic of LocalStack integration is in the configuration. We’ll set up our app to switch between LocalStack and AWS based on settings—no code changes required.

appsettings.json (Production defaults)

{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AWS": {
"Region": "us-east-1",
"UseLocalStack": false
},
"Resources": {
"BucketName": "orders-receipts",
"TableName": "Orders",
"QueueName": "orders-events"
}
}

appsettings.Development.json (LocalStack)

{
"AWS": {
"Region": "us-east-1",
"UseLocalStack": true,
"ServiceUrl": "http://localhost:4566"
}
}

When UseLocalStack is true, our SDK clients will point to LocalStack. When false, they use real AWS with your configured credentials.

Step 5: Create the Order Model

Add Order.cs:

namespace LocalStackDemo;
public record Order(
string OrderId,
string CustomerEmail,
decimal Amount,
DateTime CreatedAt
);
public record CreateOrderRequest(
string CustomerEmail,
decimal Amount
);

Step 6: Wire Up AWS Clients in Program.cs

Here’s the complete Program.cs with proper endpoint switching:

using System.Text.Json;
using Amazon;
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.SQS;
using Amazon.SQS.Model;
using LocalStackDemo;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Load configuration
var awsSection = builder.Configuration.GetSection("AWS");
var region = awsSection["Region"] ?? "us-east-1";
var useLocalStack = awsSection.GetValue<bool>("UseLocalStack");
var serviceUrl = awsSection["ServiceUrl"];
var resourcesSection = builder.Configuration.GetSection("Resources");
var bucketName = resourcesSection["BucketName"] ?? "orders-receipts";
var tableName = resourcesSection["TableName"] ?? "Orders";
var queueName = resourcesSection["QueueName"] ?? "orders-events";
// Register S3 client
builder.Services.AddSingleton<IAmazonS3>(_ =>
{
var config = new AmazonS3Config
{
RegionEndpoint = RegionEndpoint.GetBySystemName(region)
};
if (useLocalStack && !string.IsNullOrEmpty(serviceUrl))
{
config.ServiceURL = serviceUrl;
config.ForcePathStyle = true; // Required for LocalStack S3
config.UseHttp = true;
}
return new AmazonS3Client(config);
});
// Register DynamoDB client
builder.Services.AddSingleton<IAmazonDynamoDB>(_ =>
{
var config = new AmazonDynamoDBConfig
{
RegionEndpoint = RegionEndpoint.GetBySystemName(region)
};
if (useLocalStack && !string.IsNullOrEmpty(serviceUrl))
{
config.ServiceURL = serviceUrl;
config.UseHttp = true;
}
return new AmazonDynamoDBClient(config);
});
// Register SQS client
builder.Services.AddSingleton<IAmazonSQS>(_ =>
{
var config = new AmazonSQSConfig
{
RegionEndpoint = RegionEndpoint.GetBySystemName(region)
};
if (useLocalStack && !string.IsNullOrEmpty(serviceUrl))
{
config.ServiceURL = serviceUrl;
config.UseHttp = true;
}
return new AmazonSQSClient(config);
});
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
app.MapScalarApiReference();
// Health check
app.MapGet("/", () => Results.Ok(new
{
Status = "Running",
Mode = useLocalStack ? "LocalStack" : "AWS",
Timestamp = DateTime.UtcNow
}));
// Create order endpoint
app.MapPost("/orders", async (
CreateOrderRequest request,
IAmazonDynamoDB dynamoDb,
IAmazonS3 s3,
IAmazonSQS sqs) =>
{
var order = new Order(
OrderId: Guid.NewGuid().ToString(),
CustomerEmail: request.CustomerEmail,
Amount: request.Amount,
CreatedAt: DateTime.UtcNow
);
// 1. Save to DynamoDB
var putRequest = new PutItemRequest
{
TableName = tableName,
Item = new Dictionary<string, AttributeValue>
{
["OrderId"] = new(order.OrderId),
["CustomerEmail"] = new(order.CustomerEmail),
["Amount"] = new() { N = order.Amount.ToString() },
["CreatedAt"] = new(order.CreatedAt.ToString("O"))
}
};
await dynamoDb.PutItemAsync(putRequest);
// 2. Upload receipt to S3
var orderId = order.OrderId;
var receipt = $"Order Receipt\n\nOrder ID: {orderId}\nCustomer: {order.CustomerEmail}\nAmount: ${order.Amount:F2}\nDate: {order.CreatedAt:F}";
var putObjectRequest = new PutObjectRequest
{
BucketName = bucketName,
Key = $"receipts/{orderId}.txt",
ContentBody = receipt
};
await s3.PutObjectAsync(putObjectRequest);
// 3. Send message to SQS
var queueUrlResponse = await sqs.GetQueueUrlAsync(queueName);
var sendMessageRequest = new SendMessageRequest
{
QueueUrl = queueUrlResponse.QueueUrl,
MessageBody = JsonSerializer.Serialize(order)
};
await sqs.SendMessageAsync(sendMessageRequest);
return Results.Created($"/orders/{order.OrderId}", order);
});
// Get order by ID
app.MapGet("/orders/{orderId}", async (string orderId, IAmazonDynamoDB dynamoDb) =>
{
var response = await dynamoDb.GetItemAsync(new GetItemRequest
{
TableName = tableName,
Key = new Dictionary<string, AttributeValue>
{
["OrderId"] = new(orderId)
}
});
if (response.Item.Count == 0)
return Results.NotFound(new { Message = "Order not found" });
return Results.Ok(new Order(
OrderId: response.Item["OrderId"].S,
CustomerEmail: response.Item["CustomerEmail"].S,
Amount: decimal.Parse(response.Item["Amount"].N),
CreatedAt: DateTime.Parse(response.Item["CreatedAt"].S)
));
});
// List all orders
app.MapGet("/orders", async (IAmazonDynamoDB dynamoDb) =>
{
var response = await dynamoDb.ScanAsync(new ScanRequest { TableName = tableName });
var orders = response.Items.Select(item => new Order(
OrderId: item["OrderId"].S,
CustomerEmail: item["CustomerEmail"].S,
Amount: decimal.Parse(item["Amount"].N),
CreatedAt: DateTime.Parse(item["CreatedAt"].S)
));
return Results.Ok(orders);
});
// Check SQS messages (for debugging)
app.MapGet("/messages", async (IAmazonSQS sqs) =>
{
var queueUrlResponse = await sqs.GetQueueUrlAsync(queueName);
var receiveResponse = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = queueUrlResponse.QueueUrl,
MaxNumberOfMessages = 10,
WaitTimeSeconds = 1
});
return Results.Ok(receiveResponse.Messages.Select(m => new
{
m.MessageId,
m.Body,
m.ReceiptHandle
}));
});
// List S3 receipts
app.MapGet("/receipts", async (IAmazonS3 s3) =>
{
var response = await s3.ListObjectsV2Async(new ListObjectsV2Request
{
BucketName = bucketName,
Prefix = "receipts/"
});
return Results.Ok(response.S3Objects.Select(o => new
{
o.Key,
o.Size,
o.LastModified
}));
});
app.Run();

Code Walkthrough

AWS Client Registration

Each AWS client (IAmazonS3, IAmazonDynamoDB, IAmazonSQS) is registered with conditional configuration. When UseLocalStack is true:

  • ServiceURL points to http://localhost:4566
  • UseHttp = true avoids TLS issues
  • ForcePathStyle = true (S3 only) ensures bucket names work correctly with LocalStack

POST /orders

This endpoint demonstrates the complete workflow:

  1. Creates an order record in DynamoDB
  2. Uploads a receipt file to S3
  3. Publishes an event to SQS for downstream processing

GET /orders/{orderId} and GET /orders

Read operations against DynamoDB to verify data persistence.

GET /messages and GET /receipts

Debug endpoints to verify SQS messages and S3 objects were created correctly.

Step 7: Run and Test

Start LocalStack (if not already running):

Terminal window
docker compose up -d

Run the .NET application:

Terminal window
dotnet run

Navigate to http://localhost:5000/scalar/v1 to access the Scalar API documentation.

Test the Order Creation

Create an order using Scalar or curl:

Terminal window
curl -X POST http://localhost:5000/orders -H "Content-Type: application/json" -d '{"customerEmail": "[email protected]", "amount": 99.99}'

Expected response:

{
"orderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerEmail": "[email protected]",
"amount": 99.99,
"createdAt": "2025-12-26T10:30:00Z"
}

Scalar showing successful order creation

Verify Data in LocalStack

Check DynamoDB:

Terminal window
aws --endpoint-url=http://localhost:4566 dynamodb scan --table-name Orders

Check S3:

Terminal window
aws --endpoint-url=http://localhost:4566 s3 ls s3://orders-receipts/receipts/

Check SQS:

Terminal window
aws --endpoint-url=http://localhost:4566 sqs receive-message --queue-url http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-events

Verifying data in LocalStack via CLI

Step 8: CI Integration with GitHub Actions

One of the biggest wins with LocalStack is cost-free CI. Here’s a complete GitHub Actions workflow:

name: Integration Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
localstack:
image: localstack/localstack:latest
ports:
- 4566:4566
env:
SERVICES: s3,dynamodb,sqs
DEBUG: 0
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Wait for LocalStack
run: |
echo "Waiting for LocalStack to be ready..."
for i in {1..30}; do
if curl -s http://localhost:4566/_localstack/health | grep -q '"s3": "available"'; then
echo "LocalStack is ready!"
exit 0
fi
echo "Attempt $i: LocalStack not ready yet, waiting..."
sleep 2
done
echo "LocalStack failed to start"
exit 1
- name: Create AWS Resources
env:
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
run: |
aws --endpoint-url=http://localhost:4566 s3 mb s3://orders-receipts
aws --endpoint-url=http://localhost:4566 dynamodb create-table --table-name Orders --attribute-definitions AttributeName=OrderId,AttributeType=S --key-schema AttributeName=OrderId,KeyType=HASH --billing-mode PAY_PER_REQUEST
aws --endpoint-url=http://localhost:4566 sqs create-queue --queue-name orders-events
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Run tests
env:
AWS__Region: us-east-1
AWS__UseLocalStack: "true"
AWS__ServiceUrl: http://localhost:4566
Resources__BucketName: orders-receipts
Resources__TableName: Orders
Resources__QueueName: orders-events
run: dotnet test --no-build --verbosity normal

Workflow Breakdown

Let me walk through what each step does:

Services Block

services:
localstack:
image: localstack/localstack:latest
ports:
- 4566:4566
env:
SERVICES: s3,dynamodb,sqs

GitHub Actions spins up LocalStack as a Docker service container before your job runs. It’s available at localhost:4566 throughout the workflow. The SERVICES environment variable tells LocalStack which services to initialize—keeping it minimal speeds up startup.

Health Check

The Wait for LocalStack step polls the health endpoint until S3 reports as "available". This is critical—without it, your AWS CLI commands might fail because LocalStack hasn’t fully initialized. The loop tries 30 times with 2-second intervals, giving LocalStack up to 60 seconds to start.

Resource Seeding

Before tests run, we create the same resources we use locally: S3 bucket, DynamoDB table, and SQS queue. This mirrors your local development setup exactly.

Configuration Override

env:
AWS__Region: us-east-1
AWS__UseLocalStack: "true"
AWS__ServiceUrl: http://localhost:4566

Environment variables override your appsettings.json values. The double underscore (__) syntax is how ASP.NET Core maps environment variables to nested configuration keys. AWS__UseLocalStack becomes AWS:UseLocalStack in your IConfiguration.

What the Workflow Output Looks Like

When this workflow runs, you’ll see output like this:

Run echo "Waiting for LocalStack to be ready..."
Waiting for LocalStack to be ready...
Attempt 1: LocalStack not ready yet, waiting...
Attempt 2: LocalStack not ready yet, waiting...
LocalStack is ready!
Run aws --endpoint-url=http://localhost:4566 s3 mb s3://orders-receipts
make_bucket: orders-receipts
Run aws --endpoint-url=http://localhost:4566 dynamodb create-table...
{
"TableDescription": {
"TableName": "Orders",
"TableStatus": "ACTIVE",
...
}
}
Run dotnet test --no-build --verbosity normal
Determining projects to restore...
All projects are up-to-date for restore.
LocalStackDemo.Api -> /home/runner/work/.../bin/Debug/net10.0/LocalStackDemo.Api.dll
Test run for ...
Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5

The entire workflow typically completes in under 2 minutes—most of that time is restoring NuGet packages and building. The LocalStack operations themselves are nearly instant.

Cost Comparison

Let’s put some numbers to this. Say your team runs integration tests 50 times per day:

ScenarioMonthly Cost
Real AWS (DynamoDB, S3, SQS)~$50-100+ depending on usage
LocalStack in CI$0

And that’s just direct costs. You also save on:

  • Cleanup scripts to remove test data from AWS
  • Dealing with rate limits and throttling
  • Debugging failures caused by network issues
  • Managing IAM permissions for CI runners

What the Tests Verify

The sample repository includes integration tests that run against LocalStack:

  • DynamoDB_CanWriteAndReadOrder – Writes an order to DynamoDB with all fields (OrderId, CustomerEmail, Amount, CreatedAt), reads it back, and verifies the data matches. This confirms your DynamoDB table schema and SDK configuration are correct.
  • S3_CanUploadAndDownloadReceipt – Uploads a receipt file to S3, downloads it, and verifies the content is identical. This validates your S3 bucket setup and path-style URL configuration.

These tests use xUnit with a shared LocalStackFixture that creates AWS SDK clients configured for LocalStack. The same tests work in CI without modification—LocalStack provides identical behavior locally and in GitHub Actions.

Adding This to Your Repository

The workflow file lives at .github/workflows/integration-tests.yml in your repository. Once you push it, GitHub Actions automatically picks it up and runs on every push to main and every pull request.

You can see the complete workflow in the sample repository.

Limitations: When You Still Need Real AWS

LocalStack is excellent for development and CI, but it doesn’t cover everything:

What Works WellWhat Doesn’t
S3 basic operationsIAM policy evaluation
DynamoDB CRUDVPC/networking
SQS/SNS messagingTLS certificate validation
Lambda (Community)Some service-specific edge cases
Step FunctionsEventual consistency behavior

My recommendation: Use LocalStack for 90% of development and CI. Run a short test suite against a real AWS sandbox before release to catch IAM, TLS, and edge case issues.

Common Gotchas and Fixes

S3 “bucket not found” errors

Cause: LocalStack S3 requires path-style URLs, not virtual-hosted style.

Fix: Always set ForcePathStyle = true in your AmazonS3Config.

”Connection refused” errors

Cause: LocalStack isn’t running or is on a different port.

Fix: Check docker compose ps and verify port 4566 is mapped.

HTTPS/TLS errors

Cause: LocalStack uses HTTP by default.

Fix: Set UseHttp = true on all client configs, and use http:// in ServiceUrl.

Region mismatches

Cause: Different regions between clients can cause signature errors.

Fix: Use the same region (us-east-1) consistently across all clients.

Data disappears after restart

Cause: LocalStack doesn’t persist by default.

Fix: Set PERSISTENCE=1 in your docker-compose.yml and mount a volume.

Slow first request

Cause: LocalStack lazy-loads services.

Fix: Normal behavior. First request initializes the service; subsequent requests are fast.

Switching to Real AWS

When you’re ready to deploy to staging or production, the switch is simple:

  1. Set UseLocalStack to false in your configuration
  2. Remove ServiceUrl (or leave it empty)
  3. Configure real AWS credentials (IAM roles, environment variables, or AWS profiles)

Your code stays exactly the same. The only difference is which endpoint the SDK clients connect to.

{
"AWS": {
"Region": "us-east-1",
"UseLocalStack": false
}
}

With proper IAM credentials configured, your application now talks to real AWS.

Wrap-Up

LocalStack transforms how .NET teams develop AWS-integrated applications:

  • Zero cloud costs during development and CI
  • Instant feedback without network latency
  • Offline development capability
  • Reproducible environments via Docker Compose
  • Simple switching between local and cloud via configuration

The pattern we built—endpoint switching via configuration—means your production code is identical to your development code. No conditional logic, no environment-specific branches. Just clean, testable code that works everywhere.

Grab the complete source code from github.com/iammukeshm/localstack-for-dotnet-teams, spin up LocalStack, and start saving on your AWS bill today.

Have questions or run into issues? Drop a comment below—I’d love to hear how LocalStack is working for your team.

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