It is highly likely that you are working with cloud services, especially if you are within the web development sphere.

While most development in the .NET ecosystem gravitates towards Azure, AWS remains a robust, battle-tested alternative.

In this post I will try to introduce you to LocalStack, a powerful AWS service emulator.

By utilizing LocalStack you will gain access to an emulated AWS environment where you can experiment freely, speed up your development and debug seamlessly.

A case against mocking and dev accounts

Although cloud services are compelling, there's a distinct lack of tools that allow for simple, cost-effective local development and iteration.

When experimenting and iterating with cloud services, developers often resort to two common approaches:

  1. Mocks
    • Mocks often oversimplify real-world complexity

      While mocks and stubs can simulate the interface of a service, they fail to capture the nuanced behavior of real environments. AWS services oftentimes involve stateful interactions and edge cases.

    • They hinder learning and discovery

      If you're new to a service, mocks do little to teach you about its intricacies. When using the real service or an emulator you can experiment with actual API requests and responses, gain a deeper understanding of service-specific configurations, and spot inconsistencies in documentation, especially when working with less popular services.

    • Mocks can't keep up with the pace of AWS updates

      AWS frequently introduces new features and breaking changes to its services. Manually maintained mocks can quickly become outdated, leading to significant technical debt.

  2. Dev accounts
    • Cost concerns

      One of the biggest hurdles with using cloud service dev accounts is the potential for unexpectedly high costs.

      Imagine you're developing a new feature that involves multiple iterations of API calls or resource provisioning.

      Repeatedly spinning up resources or doing certain data-intensive operations can quickly add up to a hefty bill.

    • Risk of configuration mistakes

      Small but common missteps (especially when new to a service) can have significant consequences.

      Accidentally leaving resources running can silently accumulate costs. Misconfigured IAM policies can unintentionally expose data to the public creating security vulnerabilities or compliance risks.

    • Slow feedback loop

      AWS operations often take some time to propagate changes, delaying your ability to iterate and test.

Where LocalStack comes into play

LocalStack bridges the gap between local development and AWS by providing a lightweight and fully configurable AWS service emulator.

It eliminates the complexity, costs, and risks associated with manual mocking or working directly on AWS

Did I mention the best part? You can run it in Docker!

An example with AWS Simple Email Service (SES)

I've specifically picked SES as the service for this demo since it's not as utilized as - let's say Simple Storage Service (S3) (saturated topic).

On the other hand, it is still something you will likely need in your application.

Sending emails is a common task, duh...

This blog post is accompanied by a demo project, which you can find here, so if you're stuck feel free to dissect it!

1. Prerequisites

Make sure you have installed:

  • .NET SDK
  • Docker

2. Docker compose

Firstly, you will need to include LocalStack in the development docker-compose file.

I will assume, for simplicity's sake, that you are not running your application in a container and that you have no other docker-compose files in your repo.

Add the following docker-compose.yaml to your repo root:

services:
  localstack:
    container_name: "localstack"
    image: localstack/localstack:4.0.0
    ports:
      - "4566:4566"
      - "4510-4559:4510-4559"
    environment:
      - DEBUG=0
      - SERVICES=ses
    volumes:
      - "./volume:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

The most notable element in this configuration is the SERVICES variable.

LocalStack allows you to enable a specific subset of AWS services you are intending to use.

Since we are only utilizing SES, we specify it exclusively.

LocalStack accepts a comma-separated list here, so if you need S3 as well for example, we can set it to: ses,s3.

To get the valid names of services, I found it to be easiest just to send a GET request to the internal health status endpoint. In our case, if the container is up, you would head over to the http://localhost:4566/_localstack/health endpoint.

As of LocalStack v4.0.0, these are the available services:

acmapigatewaycloudcontrolcloudformationcloudwatchconfigdynamodbdynamodbstreamsec2esfirehoseiamkinesiskmslambdalogsopensearchredshiftresource-groupsresourcegroupstaggingapiroute53route53resolvers3s3controlschedulersecretsmanagersessnssqsstepfunctionssupportswftranscribe

3. AWS SDK

To utilize SES, install the AWSSDK.SimpleEmail & AWSSDK.Extensions.NETCore.Setup libraries from NuGet.

We will add the following to the Program.cs (ASP.NET Core app):

var builder = WebApplication.CreateSlimBuilder(args);

var awsOptions = builder.Configuration.GetAWSOptions();

builder.Services.AddDefaultAWSOptions(awsOptions);
builder.Services.AddAWSService<IAmazonSimpleEmailService>();

// omitted for brevity

After injecting the IAmazonSimpleEmailService to our service (or in my case a minimal endpoint), we can send requests to the SES service like so:

app.MapPost("send-email", async (
  [FromBody] SendEmailRequest request,
  [FromServices] IAmazonSimpleEmailService ses) =>
{
   await ses.SendEmailAsync(new()
   {
       Destination = new()
       {
           ToAddresses = [request.Recipient],
       },
       Message = new()
       {
           Body = new()
           {
               Text = new()
               {
                   Charset = "UTF-8",
                   Data = request.Content,
               }
           },
           Subject = new()
           {
               Charset = "UTF-8",
               Data = "Hello via LocalStack!"
           }
       }
   });

   return Results.Ok(new StatusResponse(
    $"Successfully sent email to {request.Recipient}.",
    DateTime.UtcNow.Ticks));
});

// omitted for brevity

record SendEmailRequest(
  [Required] string Recipient,
  [Required] string Content);

record StatusResponse(
  string Message,
  long TimeStamp);

However, if we attempt to run the application now we will encounter a few issues...

4. Resolving issues

  1. Service URL

    Amazon.Runtime.AmazonClientException: No RegionEndpoint or ServiceURL configured.

    Ensure you have set the AWS:ServiceUrl via appsettings.Development.json (recommended) or environment variables.

    {
      "$schema": "https://json.schemastore.org/appsettings.json",
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
    --  }
    ++  },
    ++  "AWS": {
    ++    "ServiceUrl": "http://localhost:4566"
    ++  }
    }
  2. IAM Credentials

    Amazon.Runtime.AmazonServiceException: Unable to get IAM security credentials from EC2 Instance Metadata Service.

    Ensure the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables are defined.

    For LocalStack to function, these can be set to any value. I usually do it via the launchSettings.json profile like so:

    {
     "$schema": "https://json.schemastore.org/launchsettings.json",
     "profiles": {
       "https": {
         "commandName": "Project",
         "dotnetRunMessages": true,
         "launchBrowser": true,
         "applicationUrl": "https://localhost:7172;http://localhost:5070",
         "environmentVariables": {
    --        "ASPNETCORE_ENVIRONMENT": "Development"
    ++        "ASPNETCORE_ENVIRONMENT": "Development",
    ++        "AWS_ACCESS_KEY_ID": "test",
    ++        "AWS_SECRET_ACCESS_KEY": "test"
         }
       }
     }
    }
  3. Email address not verified

    Amazon.SimpleEmail.Model.MessageRejectedException: 'Email address not verified sender@demo.app'

    Essentially, LocalStack emulates the behavior you would encounter on AWS.

    On AWS you'd have to verify that you are the owner of a domain or email address while in the Sandbox mode.

    Therefore, LocalStack requires you to verify an address or domain via the awslocalCLI tool within the container.

    Fortunately, after some research, I discovered an easy and portable way to verify an email address during container startup via LocalStack lifecycle hooks.

    1. Create the following file in the repo root: ./.localstack/ready.sh.
    2. Paste the following contents in the file (ensure that the email sender address / domain matches the one in the app):
      #!/bin/sh
      awslocal ses verify-email-identity --email-address sender@demo.app
      
      # to verify a domain use the following command
      # awslocal ses verify-domain-identity --domain demo.app
    3. Add the following mount to the localstack service in the docker-compose.yaml (make sure that the mounted file path is correct):
      services:
      
        localstack:
          container_name: "localstack"
          image: localstack/localstack:4.0.0
          ports:
            - "4566:4566"
            - "4510-4559:4510-4559"
          environment:
            - DEBUG=0
            - SERVICES=ses
          volumes:
            - "./volume:/var/lib/localstack"
            - "/var/run/docker.sock:/var/run/docker.sock"
      ++          - "./.localstack/ready.sh:/etc/localstack/init/ready.d/script.sh"
      When starting the container up the next time, you should see this in the logs:
      localstack  | 2025-01-08T10:00:00.420  INFO --- [et.reactor-0] localstack.request.aws : AWS ses.VerifyEmailIdentity => 200

5. First email!

The stage is almost set to send our first email via SES to LocalStack.

Start your application and run docker compose up in the terminal to spin up the containers.

Call the endpoint responsible for sending an email and wait for a bit.

If there are no more errors chances are the email has been received by LocalStack.

6. Checking the messages

If you prefer to use curl, run this command to get the messages received by LocalStack: curl -X GET "http://localhost:4566/_aws/ses?email=sender@demo.app".

Notice that you can set the email query parameter (sender email address).

While it may be a primitive solution, I do find the browser to be more suitable for this task, given that it's a simple GET request and the browsers often offer a quick way to pretty print JSON.

Voila, you should see something like this:

{
  "messages": [
    {
      "Id": "dyfxbdcxvptcmanx-fshojqua-fwxe-etkc-omku-hjoztihjefkq-zvpnsa",
      "Region": "us-east-1",
      "Destination": {
        "ToAddresses": ["recipient@demo.app"]
      },
      "Source": "sender@demo.app",
      "Subject": "Hello via LocalStack!",
      "Body": {
        "text_part": "Hello Bob!",
        "html_part": null
      },
      "Timestamp": "2025-01-08T11:22:43"
    }
  ]
}

Final words

LocalStack is a powerful tool for emulating AWS services locally, offering developers a safe and cost-effective way to experiment and iterate without touching the cloud.

While the Community Edition offers an impressive range of free features, it's always a good idea to review the feature coverage to ensure it meets your needs.

For advanced use cases, the licensed version might be worth considering, as it extends emulation capabilities even further.

An example of a pro feature for SES is the ability to actually send emails via an SMTP server.

Compared to Azure, LocalStack gives AWS developers a significant edge by offering a unified, feature-rich emulator.

Azure's ecosystem lacks a comparable all-in-one solution, requiring developers to juggle multiple smaller emulators.

With LocalStack in your toolkit, you're not just saving time and money - you're bringing the cloud closer to your local machine.