How We Cut AWS Hosting Costs from $12,000 to $40 a Month

How we moved a healthcare ordering system from EC2 to AWS Lambda and S3, reducing monthly hosting costs from $12,000 to $40

How We Cut AWS Hosting Costs from $12,000 to $40 a Month

We had an AWS hosting bill of $12,000 per month for a healthcare ordering platform. After moving it from EC2 to Lambda and S3, the bill came down to about $40 per month. The system still processes more than 30,000 orders a month across three regions.

I will call the platform Meridian. Some names have been changed.

AWS Lift and shift trap

How We Got to $12,000 a Month

The first move was a familiar one: take the existing virtual machines and run them as EC2 instances. Lift and shift gets an application out of the data centre quickly, but it does not change how the application consumes infrastructure. You still pay for machines whether they are busy or not.

Meridian started as an on-premises .NET application. Moving it to EC2 was the shortest route to AWS, and the application carried on working as before.

Then the invoice arrived.

EC2 Lift and Shift Architecture

The $12,000 Monthly Bill

Here’s what was running in each primary region:

ComponentInstance TypeHourly RateMonthly Cost
HL7 Processor / API / Web Service (2x)t3.2xlarge → m5.2xlarge$0.33$0.38~$560
Administrative Frontend with Citrix (8x)m5.xlarge (Windows)$0.376~$2,200
Application Load Balancers (2x)ALB-~$600
EBS Storage, snapshots, data transferVarious-~$1,380
Reserved capacity overheadRI premiums-~$800
CloudWatch, WAF, secrets, miscVarious-~$460
Total (per primary region)~$6,000

These figures exclude the database, whose cost stayed the same after the migration. There were also 2 service EC2 instances and 2 Citrix servers in a third, smaller region.

Across all three regions combined, the total landed at ~$12,000/month.

In the primary regions, factory floor operators used the Citrix-based Windows servers to reach the administrative application. Those machines had to cope with peak shift changes, but that peak load only occurred about 40% of the time. For the remaining 60%, we were paying for mostly idle compute.

What We Replaced It With

The workload varied through the day, so Lambda was a better fit. Instead of keeping servers running around the clock, we could pay when the code actually ran.

The New Architecture

AWS Serverless Architecture

The new architecture used:

  • AWS Lambda (API) - Hosts the ASP.NET Core API using Amazon.Lambda.AspNetCoreServer
  • AWS Lambda (HL7 Processor) - Processes healthcare messages from SQS
  • API Gateway HTTP API (v2) - Routes API requests to the ASP.NET Lambda
  • API Gateway REST API - Acts as a proxy for the S3-hosted frontend
  • S3 - Hosts the Angular SPA (static files)
  • SQS - Queues HL7 messages for async processing (critical for staying within Lambda timeout)
  • ElastiCache - Redis for distributed caching

The SQS Pattern for HL7 Processing

HL7 processing time can be unpredictable. Lambda has a 15-minute timeout, and leaving an API request open while the work completes is asking for trouble. We enqueue the message and return immediately:

  1. API Lambda receives the HL7 message
  2. Validates the payload and enqueues to SQS immediately
  3. Returns HTTP 202 Accepted - total time: milliseconds
  4. HL7 Processor Lambda picks up the message from SQS
  5. Processes the order in under 15 seconds

This keeps the API responsive and moves the longer work out of the request. SQS also provides the retry and dead-letter queue handling when processing fails.

API Gateway REST API as S3 Proxy

Rather than exposing the S3 bucket directly and dealing with the resulting CORS configuration, we put an API Gateway REST API in front of the Angular SPA. That gives us:

  • Custom domain support - The frontend is served from app.example.com, not bucket.s3.amazonaws.com
  • TLS termination - API Gateway handles certificates
  • No CORS complexity - API and frontend share the same origin
  • Path-based routing - /static/* and /assets/* map to S3 paths

The Terraform looks like this:

resource "aws_api_gateway_rest_api" "proxy_api" {
  name               = var.api_name
  binary_media_types = ["*/*"]
  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

resource "aws_api_gateway_integration" "root_get_integration" {
  rest_api_id             = aws_api_gateway_rest_api.proxy_api.id
  resource_id             = aws_api_gateway_rest_api.proxy_api.root_resource_id
  http_method             = aws_api_gateway_method.root_get_method.http_method
  type                    = "AWS"
  integration_http_method = "GET"
  uri                     = "arn:aws:apigateway:${data.aws_region.current.region}:s3:path/${var.site_bucket_name}/index.html"
  credentials             = var.s3_role
}

The {proxy+} catch-all route returns index.html for any unmatched path, enabling client-side routing in the Angular SPA.

Running ASP.NET Core in Lambda

Lambda is not limited to a collection of tiny functions. We run the entire ASP.NET Core application in it. This is the entry point:

public class LambdaEntryPoint : Amazon.Lambda.AspNetCoreServer.APIGatewayHttpApiV2ProxyFunction
{
    protected override void Init(IHostBuilder builder)
    {
        Log.Logger = new LoggerConfiguration()
            .Enrich.FromLogContext()
            .WriteTo.Console()                
            .CreateLogger();
        
        builder
            .UseSerilog((ctx, config) =>
            {
                config.ReadFrom.Configuration(ctx.Configuration);
            })
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder
                    .UseStartup<Startup>()
                    .UseLambdaServer();
            });
    }
}

Startup.cs stays almost identical to the version used for traditional ASP.NET Core hosting. Controllers, dependency injection and middleware continue to work.

Provisioning It with Terraform

This Terraform provisions the Lambda function:

resource "aws_lambda_function" "lambda" {
  function_name    = local.function_name
  description      = local.function_description
  filename         = local.function_package_path
  source_code_hash = filebase64sha256(local.function_package_path)
  role             = data.aws_iam_role.lambda.arn
  handler          = local.function_handler
  runtime          = "dotnet8"
  memory_size      = 1024
  timeout          = 30
  
  logging_config {
    application_log_level = "INFO"
    log_format            = "JSON"
    log_group             = aws_cloudwatch_log_group.lambda.name
    system_log_level      = "INFO"
  }
  
  vpc_config {
    security_group_ids = [data.aws_security_group.lambda.id]
    subnet_ids         = local.subnet_ids
  }
  
  environment {
    variables = var.function_environment_variables
  }
}

The API Gateway routes traffic to it:

resource "aws_apigatewayv2_api" "api_lambda" {
  name          = local.api_name
  protocol_type = "HTTP"
  
  tags = merge(var.tags, { "Name" = local.api_name })
}

resource "aws_lambda_permission" "lambda" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.lambda.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api_lambda.execution_arn}/*/*"
}

For the static Angular frontend, we use S3 behind a classic API Gateway REST API proxy:

resource "aws_s3_bucket" "site_bucket" {
  bucket = var.site_bucket_name
  
  tags = merge(var.tags, {
    Name = var.site_bucket_name
  })
}

resource "aws_s3_bucket_website_configuration" "website" {
  bucket = aws_s3_bucket.site_bucket.id
  
  index_document {
    suffix = "index.html"
  }
  
  error_document {
    key = "error.html"
  }
}

resource "aws_api_gateway_rest_api" "proxy_api" {
  name               = var.api_name
  description        = "Client proxy"
  binary_media_types = ["*/*"]
  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

# Root route -> S3 index.html (SPA entry point)
resource "aws_api_gateway_integration" "root_get_integration" {
  rest_api_id             = aws_api_gateway_rest_api.proxy_api.id
  resource_id             = aws_api_gateway_rest_api.proxy_api.root_resource_id
  http_method             = aws_api_gateway_method.root_get_method.http_method
  type                    = "AWS"
  integration_http_method = "GET"
  uri                     = "arn:aws:apigateway:${data.aws_region.current.region}:s3:path/${var.site_bucket_name}/index.html"
  credentials             = var.s3_role
}

The $40 Monthly Bill

After migration, the new cost structure across all regions combined:

ComponentPricing ModelMonthly Cost
Lambda (API)Pay per invocation~$12
Lambda (HL7 Processor)Pay per invocation~$8
API Gateway HTTP API (v2)Per request~$5
API Gateway REST API (S3 proxy)Per request~$3
S3 (static hosting)Storage + requests~$2
CloudWatch LogsIngestion + storage~$10
Total (all regions)~$40

That is $40 instead of $12,000 per month across all regions, a 99.7% reduction.

Cost Comparison Chart The Lambda and S3 costs are small enough to appear as “Others” on the chart.

1. We Stopped Paying for Idle Compute

EC2 instances run 24/7. Lambda runs when invoked. For an application used mainly during working hours, that can mean paying for roughly 50 hours of actual compute rather than 720 hours of server uptime.

2. Capacity Follows Demand

With EC2, we provisioned for peak load. Lambda scales automatically, so we no longer keep that peak capacity running when it is not needed.

3. There Are No Servers for Us to Maintain

There is no operating system for us to patch or update, and no server capacity to plan. AWS manages that infrastructure.

4. SQS Replaced the Always-On Workers

Background processing moved from dedicated EC2 workers to Lambda functions triggered by SQS. When the queue is empty, no compute runs.

Trade-offs and Considerations

This was not a free lunch. We had to account for:

  • Cold starts - First request after idle period is slower (~1-3 seconds for .NET). Use Provisioned Concurrency if this is critical.
  • Execution limits - Lambda has a 15-minute timeout. Long-running processes need SQS queuing (as we did for HL7 processing).
  • VPC considerations - Lambda in VPC adds latency. Plan your network architecture carefully.
  • Observability - Structured logging is essential. We use Serilog with CloudWatch Logs and output JSON so that we can query it properly.

The Migration Path

We did not rewrite everything in one go. The migration was gradual:

  1. Platform-agnostic rewrite - Migrated from .NET Framework to .NET Core for cross-platform compatibility
  2. Lambda-ify the API - Added Lambda entry point alongside traditional hosting (same codebase runs both ways)
  3. Deploy in parallel - Ran both EC2 and Lambda, routing traffic gradually via weighted DNS
  4. Migrate static assets - Moved Angular app to S3 behind API Gateway REST API proxy
  5. Decommission EC2 - Once stable, terminated the instances

We completed the migration without customer-facing downtime.

The result

Lift and shift did its job: it got the application into AWS quickly. It was not a sensible place to leave this particular workload. Reworking the hosting around Lambda, SQS and S3 removed most of the idle infrastructure cost.

For Meridian across three regions:

  • Before: ~$12,000/month (EC2 lift and shift)
  • After: ~$40/month (Lambda + S3)
  • Annual savings: ~$143,500

The saving is roughly $144,000 per year for an application processing more than 30,000 healthcare orders each month.

I would not claim that every EC2 workload belongs in Lambda. Persistent connections, long-running work and predictable high utilisation can point elsewhere. For variable workloads like this one, though, the original EC2 estate was an expensive way to spend much of the day doing very little.


Resources