Cross-Region .NET Microservices with AWS App Mesh and ECS Fargate
What It Took to Build a Distributed App That Stays Fast — Everywhere.
Cross-Region .NET Microservices with AWS App Mesh and ECS Fargate
What It Took to Build a Distributed App That Stays Fast — Everywhere.

Created by Author using Canva
Two years ago, I wouldn’t have thought twice about spinning up a .NET microservice in a single AWS region and calling it a day. But things change when your customers span three continents and your latency-sensitive features start to expose every bottleneck in your stack. I learned that the hard way.
We had a seemingly simple goal: to make our .NET 8-based SaaS platform available in North America, Europe, and Asia, with region-specific routing, zero-downtime deployments, and minimal developer friction. What started as a routine infra upgrade turned into a full-blown architectural shift — one where AWS App Mesh and ECS Fargate became the unexpected heroes.
Here’s what happened — and what I wish someone had told me before I started.
It started with the usual symptom: latency complaints. At first, we blamed it on the client. But once we ran distributed X-Ray traces, we found the real culprit — cross-region traffic from users in Frankfurt hitting services in Oregon.
We needed cross-region service discovery. But not just any discovery. We wanted transparent communication between services, zero config changes in our .NET codebase, and full control over traffic routing. After some trial and error with Route 53 latency records and global ALBs, we realized it wasn’t enough. We needed a proper service mesh.
That’s when App Mesh entered the picture.
Deploying .NET microservices into multiple AWS regions using ECS Fargate seems straightforward — until you have to stitch them together. Here’s the tricky part: ECS services don’t natively communicate across regions, and setting up peering or VPNs quickly becomes an operational burden.
So instead, we paired each region with its own Fargate cluster and used AWS Cloud Map for local service discovery. Then App Mesh handled the rest. Each service had its own virtual node in the mesh. Traffic routing, failovers, retries, even observability — it was all abstracted out of the app code.
Here’s a stripped-down version of how one of our .NET services was registered:
{
"virtualNodeName": "orders-service-eu",
"serviceDiscovery": {
"awsCloudMap": {
"namespaceName": "internal.local",
"serviceName": "orders"
}
},
"listeners": [{
"portMapping": {
"port": 80,
"protocol": "http"
}
}]
}
The corresponding sidecar proxy — Envoy — was configured automatically by App Mesh, injecting routing rules and retry logic via custom CRDs. Our .NET 8 services never knew the difference.
From the code side, things stayed boring — and that’s exactly what we wanted.
Our microservices exposed simple HTTP APIs via Minimal APIs in .NET 8. No SDKs, no service registries baked into the app. Here’s one of the services:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
var app = builder.Build();
app.MapGet("/api/orders", async (HttpClient http, IConfiguration config) =>
{
var inventoryService = config["Services:Inventory"];
var response = await http.GetAsync($"{inventoryService}/api/inventory");
return Results.Ok(await response.Content.ReadAsStringAsync());
});
app.Run();
We used parameterized environment overrides via appsettings.Production.json for each region, injected during deployment. That way, we could define the service URLs like:
"Services": {
"Inventory": "http://inventory.internal.local"
}
The real routing was handled by App Mesh — failover, region priorities, retries, circuit breaking — all without touching this code again.
But here’s where it got interesting: global traffic management.
We used weighted virtual routers in App Mesh to gradually shift traffic from the Oregon version of a service to the Singapore one. It looked something like this:
{
"routes": [{
"httpRoute": {
"match": {
"prefix": "/api/orders"
},
"action": {
"weightedTargets": [
{ "virtualNode": "orders-service-us", "weight": 80 },
{ "virtualNode": "orders-service-ap", "weight": 20 }
]
}
}
}]
}
This let us test new versions in APAC without risking global outages. Want 50% traffic in Frankfurt and 50% in N. Virginia? Just update the weights. Need to fail over entirely to a fallback region? One line change. App Mesh made it all feel like traffic control for microservices.
One unexpected benefit? Observability.
Because Envoy proxies everything, we got out-of-the-box metrics like p90 latency, connection retries, 5xx error rates per route — all viewable in AWS CloudWatch or pushed into Prometheus. And since App Mesh integrates with AWS X-Ray, we could trace requests across regional boundaries — like seeing a user order in Tokyo hop through Singapore, then Oregon, and back. This helped us debug what used to be “heisenbugs” in seconds.
If you’re thinking of doing something similar, here’s what I’d do differently:
- Start with one region. Make sure your services are “mesh-ready” — i.e., no hardcoded addresses, use local discovery, keep HTTP-based APIs.
- Bake in observability early. Use AWS Distro for OpenTelemetry to instrument your .NET code if X-Ray alone isn’t enough.
- Abstract your service URLs. Use config files, feature flags, or secrets management to swap endpoints easily per region.
- Keep your sidecars invisible. App developers shouldn’t need to know the mesh exists. All mesh logic should live in the infra layer.
- Use CDK. Don’t handwrite the App Mesh JSON. We used CDK’s TypeScript bindings to generate everything — from virtual nodes to route weights — per environment.
The end result? A globally available .NET application that feels local to every user. No VPNs. No latency spikes. And deployments that feel like toggling a feature flag.
This isn’t theory. It’s a battle-tested setup that took our API latency down by 300ms in Europe and saved us three major outages last quarter alone.
So, if you’re running .NET in production and your customer base is global — App Mesh and Fargate are more than buzzwords. They’re the missing pieces to finally get scale and sanity.
And trust me, once you see your Tokyo traffic hit Tokyo pods with zero config changes in .NET… you’ll never go back.
Full Code Examples
Orders Service
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
using System.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddHttpClient();
builder.Services.AddHealthChecks();
builder.Services.AddLogging();
// Add OpenTelemetry for observability
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddConsoleExporter());
var app = builder.Build();
// Configure the HTTP request pipeline
app.UseRouting();
// Health check endpoint for ECS
app.MapHealthChecks("/health");
// Orders API endpoints
app.MapGet("/api/orders", async (HttpClient http, IConfiguration config, ILogger<Program> logger) =>
{
try
{
var inventoryService = config["Services:Inventory"];
var userService = config["Services:User"];
logger.LogInformation("Fetching orders data from inventory: {InventoryService}", inventoryService);
// Call inventory service to get available items
var inventoryResponse = await http.GetAsync($"{inventoryService}/api/inventory");
inventoryResponse.EnsureSuccessStatusCode();
var inventoryData = await inventoryResponse.Content.ReadAsStringAsync();
var inventory = JsonSerializer.Deserialize<List<InventoryItem>>(inventoryData);
// Mock orders data - in real scenario, this would come from database
var orders = new List<Order>
{
new Order { Id = 1, CustomerId = 101, Items = inventory?.Take(2).ToList() ?? new List<InventoryItem>(), Status = "Processing", CreatedAt = DateTime.UtcNow.AddDays(-1) },
new Order { Id = 2, CustomerId = 102, Items = inventory?.Skip(1).Take(1).ToList() ?? new List<InventoryItem>(), Status = "Shipped", CreatedAt = DateTime.UtcNow.AddDays(-2) }
};
return Results.Ok(new { orders, region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown" });
}
catch (Exception ex)
{
logger.LogError(ex, "Error fetching orders");
return Results.Problem("Unable to fetch orders", statusCode: 500);
}
});
app.MapGet("/api/orders/{id:int}", async (int id, HttpClient http, IConfiguration config, ILogger<Program> logger) =>
{
try
{
var inventoryService = config["Services:Inventory"];
logger.LogInformation("Fetching order {OrderId}", id);
// Call inventory service
var inventoryResponse = await http.GetAsync($"{inventoryService}/api/inventory");
inventoryResponse.EnsureSuccessStatusCode();
var inventoryData = await inventoryResponse.Content.ReadAsStringAsync();
var inventory = JsonSerializer.Deserialize<List<InventoryItem>>(inventoryData);
var order = new Order
{
Id = id,
CustomerId = 100 + id,
Items = inventory?.Take(1).ToList() ?? new List<InventoryItem>(),
Status = "Processing",
CreatedAt = DateTime.UtcNow
};
return Results.Ok(new { order, region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown" });
}
catch (Exception ex)
{
logger.LogError(ex, "Error fetching order {OrderId}", id);
return Results.Problem($"Unable to fetch order {id}", statusCode: 500);
}
});
app.MapPost("/api/orders", async ([FromBody] CreateOrderRequest request, HttpClient http, IConfiguration config, ILogger<Program> logger) =>
{
try
{
var inventoryService = config["Services:Inventory"];
var userService = config["Services:User"];
logger.LogInformation("Creating new order for customer {CustomerId}", request.CustomerId);
// Validate customer exists
var customerResponse = await http.GetAsync($"{userService}/api/users/{request.CustomerId}");
if (!customerResponse.IsSuccessStatusCode)
{
return Results.BadRequest("Customer not found");
}
// Check inventory availability
var inventoryResponse = await http.GetAsync($"{inventoryService}/api/inventory");
inventoryResponse.EnsureSuccessStatusCode();
var inventoryData = await inventoryResponse.Content.ReadAsStringAsync();
var inventory = JsonSerializer.Deserialize<List<InventoryItem>>(inventoryData);
// Create new order
var newOrder = new Order
{
Id = Random.Shared.Next(1000, 9999),
CustomerId = request.CustomerId,
Items = request.Items?.Select(itemId => inventory?.FirstOrDefault(i => i.Id == itemId)).Where(i => i != null).ToList() ?? new List<InventoryItem>(),
Status = "Created",
CreatedAt = DateTime.UtcNow
};
logger.LogInformation("Order {OrderId} created successfully", newOrder.Id);
return Results.Created($"/api/orders/{newOrder.Id}", new { order = newOrder, region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown" });
}
catch (Exception ex)
{
logger.LogError(ex, "Error creating order");
return Results.Problem("Unable to create order", statusCode: 500);
}
});
app.Run();
// Data models
public record Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public List<InventoryItem> Items { get; set; } = new();
public string Status { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
public record InventoryItem
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Quantity { get; set; }
}
public record CreateOrderRequest
{
public int CustomerId { get; set; }
public List<int> Items { get; set; } = new();
}
Inventory Service
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddHttpClient();
builder.Services.AddHealthChecks();
builder.Services.AddLogging();
// Add OpenTelemetry for observability
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddConsoleExporter());
var app = builder.Build();
// Configure the HTTP request pipeline
app.UseRouting();
// Health check endpoint for ECS
app.MapHealthChecks("/health");
// In-memory inventory data (in real scenario, this would be a database)
var inventory = new List<InventoryItem>
{
new InventoryItem { Id = 1, Name = "Laptop", Price = 999.99m, Quantity = 50, Category = "Electronics" },
new InventoryItem { Id = 2, Name = "Mouse", Price = 29.99m, Quantity = 200, Category = "Electronics" },
new InventoryItem { Id = 3, Name = "Keyboard", Price = 79.99m, Quantity = 150, Category = "Electronics" },
new InventoryItem { Id = 4, Name = "Monitor", Price = 299.99m, Quantity = 75, Category = "Electronics" },
new InventoryItem { Id = 5, Name = "Headphones", Price = 199.99m, Quantity = 100, Category = "Electronics" }
};
// Inventory API endpoints
app.MapGet("/api/inventory", (ILogger<Program> logger) =>
{
logger.LogInformation("Fetching all inventory items");
return Results.Ok(new
{
items = inventory,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapGet("/api/inventory/{id:int}", (int id, ILogger<Program> logger) =>
{
logger.LogInformation("Fetching inventory item {ItemId}", id);
var item = inventory.FirstOrDefault(i => i.Id == id);
if (item == null)
{
logger.LogWarning("Inventory item {ItemId} not found", id);
return Results.NotFound($"Item with ID {id} not found");
}
return Results.Ok(new
{
item,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapGet("/api/inventory/category/{category}", (string category, ILogger<Program> logger) =>
{
logger.LogInformation("Fetching inventory items for category {Category}", category);
var items = inventory.Where(i => i.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList();
return Results.Ok(new
{
items,
category,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapPost("/api/inventory", ([FromBody] InventoryItem newItem, ILogger<Program> logger) =>
{
logger.LogInformation("Adding new inventory item: {ItemName}", newItem.Name);
newItem.Id = inventory.Max(i => i.Id) + 1;
inventory.Add(newItem);
logger.LogInformation("Inventory item {ItemId} added successfully", newItem.Id);
return Results.Created($"/api/inventory/{newItem.Id}", new
{
item = newItem,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapPut("/api/inventory/{id:int}/quantity", (int id, [FromBody] UpdateQuantityRequest request, ILogger<Program> logger) =>
{
logger.LogInformation("Updating quantity for inventory item {ItemId} to {NewQuantity}", id, request.Quantity);
var item = inventory.FirstOrDefault(i => i.Id == id);
if (item == null)
{
logger.LogWarning("Inventory item {ItemId} not found for quantity update", id);
return Results.NotFound($"Item with ID {id} not found");
}
item.Quantity = request.Quantity;
logger.LogInformation("Quantity updated successfully for item {ItemId}", id);
return Results.Ok(new
{
item,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapDelete("/api/inventory/{id:int}", (int id, ILogger<Program> logger) =>
{
logger.LogInformation("Deleting inventory item {ItemId}", id);
var item = inventory.FirstOrDefault(i => i.Id == id);
if (item == null)
{
logger.LogWarning("Inventory item {ItemId} not found for deletion", id);
return Results.NotFound($"Item with ID {id} not found");
}
inventory.Remove(item);
logger.LogInformation("Inventory item {ItemId} deleted successfully", id);
return Results.Ok(new
{
message = "Item deleted successfully",
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.Run();
// Data models
public record InventoryItem
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Quantity { get; set; }
public string Category { get; set; } = string.Empty;
}
public record UpdateQuantityRequest
{
public int Quantity { get; set; }
}
Users Service
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddHttpClient();
builder.Services.AddHealthChecks();
builder.Services.AddLogging();
// Add OpenTelemetry for observability
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddConsoleExporter());
var app = builder.Build();
// Configure the HTTP request pipeline
app.UseRouting();
// Health check endpoint for ECS
app.MapHealthChecks("/health");
// In-memory user data (in real scenario, this would be a database)
var users = new List<User>
{
new User { Id = 101, Name = "John Doe", Email = "john@example.com", Region = "US-East", CreatedAt = DateTime.UtcNow.AddDays(-30) },
new User { Id = 102, Name = "Jane Smith", Email = "jane@example.com", Region = "EU-West", CreatedAt = DateTime.UtcNow.AddDays(-25) },
new User { Id = 103, Name = "Akira Tanaka", Email = "akira@example.com", Region = "APAC", CreatedAt = DateTime.UtcNow.AddDays(-20) },
new User { Id = 104, Name = "Maria Garcia", Email = "maria@example.com", Region = "US-West", CreatedAt = DateTime.UtcNow.AddDays(-15) },
new User { Id = 105, Name = "Hans Mueller", Email = "hans@example.com", Region = "EU-Central", CreatedAt = DateTime.UtcNow.AddDays(-10) }
};
// User API endpoints
app.MapGet("/api/users", (ILogger<Program> logger) =>
{
logger.LogInformation("Fetching all users");
return Results.Ok(new
{
users,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapGet("/api/users/{id:int}", (int id, ILogger<Program> logger) =>
{
logger.LogInformation("Fetching user {UserId}", id);
var user = users.FirstOrDefault(u => u.Id == id);
if (user == null)
{
logger.LogWarning("User {UserId} not found", id);
return Results.NotFound($"User with ID {id} not found");
}
return Results.Ok(new
{
user,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapGet("/api/users/region/{region}", (string region, ILogger<Program> logger) =>
{
logger.LogInformation("Fetching users for region {Region}", region);
var regionUsers = users.Where(u => u.Region.Equals(region, StringComparison.OrdinalIgnoreCase)).ToList();
return Results.Ok(new
{
users: regionUsers,
requestedRegion = region,
serviceRegion = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapPost("/api/users", ([FromBody] CreateUserRequest request, ILogger<Program> logger) =>
{
logger.LogInformation("Creating new user: {UserName}", request.Name);
var newUser = new User
{
Id = users.Max(u => u.Id) + 1,
Name = request.Name,
Email = request.Email,
Region = request.Region,
CreatedAt = DateTime.UtcNow
};
users.Add(newUser);
logger.LogInformation("User {UserId} created successfully", newUser.Id);
return Results.Created($"/api/users/{newUser.Id}", new
{
user = newUser,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapPut("/api/users/{id:int}", (int id, [FromBody] UpdateUserRequest request, ILogger<Program> logger) =>
{
logger.LogInformation("Updating user {UserId}", id);
var user = users.FirstOrDefault(u => u.Id == id);
if (user == null)
{
logger.LogWarning("User {UserId} not found for update", id);
return Results.NotFound($"User with ID {id} not found");
}
user.Name = request.Name ?? user.Name;
user.Email = request.Email ?? user.Email;
user.Region = request.Region ?? user.Region;
logger.LogInformation("User {UserId} updated successfully", id);
return Results.Ok(new
{
user,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapDelete("/api/users/{id:int}", (int id, ILogger<Program> logger) =>
{
logger.LogInformation("Deleting user {UserId}", id);
var user = users.FirstOrDefault(u => u.Id == id);
if (user == null)
{
logger.LogWarning("User {UserId} not found for deletion", id);
return Results.NotFound($"User with ID {id} not found");
}
users.Remove(user);
logger.LogInformation("User {UserId} deleted successfully", id);
return Results.Ok(new
{
message = "User deleted successfully",
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
});
app.MapGet("/api/users/{id:int}/profile", async (int id, HttpClient http, IConfiguration config, ILogger<Program> logger) =>
{
logger.LogInformation("Fetching user profile for {UserId}", id);
var user = users.FirstOrDefault(u => u.Id == id);
if (user == null)
{
logger.LogWarning("User {UserId} not found", id);
return Results.NotFound($"User with ID {id} not found");
}
try
{
// Example of cross-service communication - get user's orders
var ordersService = config["Services:Orders"];
var ordersResponse = await http.GetAsync($"{ordersService}/api/orders");
var ordersData = ordersResponse.IsSuccessStatusCode
? await ordersResponse.Content.ReadAsStringAsync()
: "[]";
var profile = new UserProfile
{
User = user,
Orders = ordersData,
LastAccessedAt = DateTime.UtcNow
};
return Results.Ok(new
{
profile,
region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "unknown",
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error fetching user profile for {UserId}", id);
return Results.Problem("Unable to fetch user profile", statusCode: 500);
}
});
app.Run();
// Data models
public record User
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Region { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
public record CreateUserRequest
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Region { get; set; } = string.Empty;
}
public record UpdateUserRequest
{
public string? Name { get; set; }
public string? Email { get; set; }
public string? Region { get; set; }
}
public record UserProfile
{
public User User { get; set; } = new();
public string Orders { get; set; } = string.Empty;
public DateTime LastAccessedAt { get; set; }
}
CDK Infrastructure
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as appmesh from 'aws-cdk-lib/aws-appmesh';
import * as servicediscovery from 'aws-cdk-lib/aws-servicediscovery';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import { Construct } from 'constructs';
export interface MicroserviceStackProps extends cdk.StackProps {
region: string;
meshName: string;
serviceName: string;
containerImage: string;
containerPort: number;
desiredCount: number;
cpu: number;
memory: number;
serviceUrls: { [key: string]: string };
}
export class MicroserviceStack extends cdk.Stack {
public readonly mesh: appmesh.Mesh;
public readonly service: ecs.FargateService;
public readonly virtualNode: appmesh.VirtualNode;
public readonly loadBalancer: elbv2.ApplicationLoadBalancer;
constructor(scope: Construct, id: string, props: MicroserviceStackProps) {
super(scope, id, props);
// Create or import VPC
const vpc = new ec2.Vpc(this, 'VPC', {
maxAzs: 2,
natGateways: 1,
subnetConfiguration: [
{
cidrMask: 24,
name: 'Public',
subnetType: ec2.SubnetType.PUBLIC,
},
{
cidrMask: 24,
name: 'Private',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
},
],
});
// Create or import App Mesh
this.mesh = new appmesh.Mesh(this, 'Mesh', {
meshName: props.meshName,
});
// Create Cloud Map namespace for service discovery
const namespace = new servicediscovery.PrivateDnsNamespace(this, 'Namespace', {
name: 'internal.local',
vpc,
});
// Create ECS cluster
const cluster = new ecs.Cluster(this, 'Cluster', {
vpc,
clusterName: `${props.serviceName}-cluster-${props.region}`,
});
// Create log group
const logGroup = new logs.LogGroup(this, 'LogGroup', {
logGroupName: `/aws/ecs/${props.serviceName}`,
retention: logs.RetentionDays.ONE_WEEK,
});
// Create task definition
const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition', {
memoryLimitMiB: props.memory,
cpu: props.cpu,
});
// Add App Mesh proxy configuration
taskDefinition.addToTaskRolePolicy(
new cdk.aws_iam.PolicyStatement({
effect: cdk.aws_iam.Effect.ALLOW,
actions: [
'appmesh:StreamAggregatedResources',
'acm:ExportCertificate',
'acm-pca:GetCertificateAuthorityCertificate'
],
resources: ['*'],
})
);
// Create virtual node
this.virtualNode = new appmesh.VirtualNode(this, 'VirtualNode', {
mesh: this.mesh,
virtualNodeName: `${props.serviceName}-${props.region}`,
serviceDiscovery: appmesh.ServiceDiscovery.cloudMap(
namespace.createService('ServiceDiscovery', {
name: props.serviceName,
dnsRecordType: servicediscovery.DnsRecordType.A,
dnsTtl: cdk.Duration.seconds(60),
})
),
listeners: [
appmesh.VirtualNodeListener.http({
port: props.containerPort,
healthCheck: appmesh.HealthCheck.http({
healthyThreshold: 2,
interval: cdk.Duration.seconds(30),
path: '/health',
timeout: cdk.Duration.seconds(5),
unhealthyThreshold: 3,
}),
}),
],
});
// Add Envoy proxy container
const envoyContainer = taskDefinition.addContainer('EnvoyProxy', {
image: ecs.ContainerImage.fromRegistry('public.ecr.aws/appmesh/aws-appmesh-envoy:v1.25.4.0-prod'),
memoryLimitMiB: 256,
essential: true,
environment: {
APPMESH_VIRTUAL_NODE_NAME: `mesh/${props.meshName}/virtualNode/${props.serviceName}-${props.region}`,
AWS_REGION: props.region,
},
logging: ecs.LogDrivers.awsLogs({
streamPrefix: 'envoy',
logGroup,
}),
user: '1337',
healthCheck: {
command: ['CMD-SHELL', 'curl -s http://localhost:9901/server_info | grep state | grep -q LIVE'],
interval: cdk.Duration.seconds(5),
timeout: cdk.Duration.seconds(2),
retries: 3,
},
});
envoyContainer.addPortMappings({
containerPort: 9901,
protocol: ecs.Protocol.TCP,
});
// Add application container
const appContainer = taskDefinition.addContainer('AppContainer', {
image: ecs.ContainerImage.fromRegistry(props.containerImage),
memoryLimitMiB: props.memory - 256,
essential: true,
environment: {
ASPNETCORE_ENVIRONMENT: 'Production',
ASPNETCORE_URLS: `http://*:${props.containerPort}`,
AWS_REGION: props.region,
...Object.fromEntries(
Object.entries(props.serviceUrls).map(([key, value]) => [`Services__${key}`, value])
),
},
logging: ecs.LogDrivers.awsLogs({
streamPrefix: 'app',
logGroup,
}),
dependsOn: [
{
container: envoyContainer,
condition: ecs.ContainerDependencyCondition.HEALTHY,
},
],
});
appContainer.addPortMappings({
containerPort: props.containerPort,
protocol: ecs.Protocol.TCP,
});
// Create Fargate service
this.service = new ecs.FargateService(this, 'Service', {
cluster,
taskDefinition,
desiredCount: props.desiredCount,
assignPublicIp: false,
cloudMapOptions: {
cloudMapNamespace: namespace,
name: props.serviceName,
dnsRecordType: servicediscovery.DnsRecordType.A,
dnsTtl: cdk.Duration.seconds(60),
},
enableExecuteCommand: true,
});
// Create Application Load Balancer
this.loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
vpc,
internetFacing: true,
loadBalancerName: `${props.serviceName}-alb-${props.region}`,
});
// Create target group
const targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', {
port: props.containerPort,
protocol: elbv2.ApplicationProtocol.HTTP,
vpc,
targetType: elbv2.TargetType.IP,
healthCheck: {
path: '/health',
healthyHttpCodes: '200',
interval: cdk.Duration.seconds(30),
timeout: cdk.Duration.seconds(5),
healthyThresholdCount: 2,
unhealthyThresholdCount: 3,
},
});
// Attach Fargate service to target group
this.service.attachToApplicationTargetGroup(targetGroup);
// Create listener
this.loadBalancer.addListener('Listener', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
defaultTargetGroups: [targetGroup],
});
// Output important values
new cdk.CfnOutput(this, 'LoadBalancerDNS', {
value: this.loadBalancer.loadBalancerDnsName,
description: 'Application Load Balancer DNS name',
});
new cdk.CfnOutput(this, 'VirtualNodeArn', {
value: this.virtualNode.virtualNodeArn,
description: 'App Mesh Virtual Node ARN',
});
new cdk.CfnOutput(this, 'ServiceArn', {
value: this.service.serviceArn,
description: 'ECS Fargate Service ARN',
});
}
}
export class CrossRegionMeshStack extends cdk.Stack {
public readonly mesh: appmesh.Mesh;
public readonly virtualRouter: appmesh.VirtualRouter;
public readonly virtualService: appmesh.VirtualService;
constructor(scope: Construct, id: string, props: cdk.StackProps & {
meshName: string;
serviceName: string;
virtualNodes: appmesh.VirtualNode[];
routeWeights: { [region: string]: number };
}) {
super(scope, id, props);
// Create or import the mesh
this.mesh = new appmesh.Mesh(this, 'CrossRegionMesh', {
meshName: props.meshName,
});
// Create virtual router for traffic management
this.virtualRouter = new appmesh.VirtualRouter(this, 'VirtualRouter', {
mesh: this.mesh,
virtualRouterName: `${props.serviceName}-router`,
listeners: [
appmesh.VirtualRouterListener.http({
port: 80,
}),
],
});
// Create weighted targets for the route
const weightedTargets = Object.entries(props.routeWeights).map(([region, weight]) => {
const virtualNode = props.virtualNodes.find(node =>
node.virtualNodeName.includes(region)
);
if (!virtualNode) {
throw new Error(`Virtual node for region ${region} not found`);
}
return {
virtualNode,
weight,
};
});
// Create route with weighted targets
this.virtualRouter.addRoute('Route', {
routeSpec: appmesh.RouteSpec.http({
weightedTargets,
match: {
path: appmesh.HttpRoutePathMatch.startsWith('/api'),
},
timeout: {
idle: cdk.Duration.seconds(30),
perRequest: cdk.Duration.seconds(10),
},
retryPolicy: {
retryAttempts: 3,
retryTimeout: cdk.Duration.seconds(5),
httpRetryEvents: [
appmesh.HttpRetryEvent.SERVER_ERROR,
appmesh.HttpRetryEvent.GATEWAY_ERROR,
],
},
}),
});
// Create virtual service
this.virtualService = new appmesh.VirtualService(this, 'VirtualService', {
virtualServiceProvider: appmesh.VirtualServiceProvider.virtualRouter(this.virtualRouter),
virtualServiceName: `${props.serviceName}.internal.local`,
});
// Output mesh information
new cdk.CfnOutput(this, 'MeshName', {
value: this.mesh.meshName,
description: 'App Mesh name',
});
new cdk.CfnOutput(this, 'VirtualServiceName', {
value: this.virtualService.virtualServiceName,
description: 'Virtual Service name',
});
}
}
// Multi-region deployment orchestrator
export class MultiRegionDeploymentStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
const meshName = 'cross-region-mesh';
const regions = ['us-east-1', 'eu-west-1', 'ap-southeast-1'];
// Service configuration
const services = [
{
name: 'orders',
image: 'your-account.dkr.ecr.us-east-1.amazonaws.com/orders-service:latest',
port: 80,
cpu: 256,
memory: 512,
desiredCount: 2,
},
{
name: 'inventory',
image: 'your-account.dkr.ecr.us-east-1.amazonaws.com/inventory-service:latest',
port: 80,
cpu: 256,
memory: 512,
desiredCount: 2,
},
{
name: 'users',
image: 'your-account.dkr.ecr.us-east-1.amazonaws.com/user-service:latest',
port: 80,
cpu: 256,
memory: 512,
desiredCount: 2,
},
];
// Deploy services across regions
regions.forEach(region => {
services.forEach(service => {
const serviceUrls = {
Orders: 'http://orders.internal.local',
Inventory: 'http://inventory.internal.local',
Users: 'http://users.internal.local',
};
new MicroserviceStack(this, `${service.name}-${region}`, {
region,
meshName,
serviceName: service.name,
containerImage: service.image,
containerPort: service.port,
desiredCount: service.desiredCount,
cpu: service.cpu,
memory: service.memory,
serviceUrls,
env: {
account: this.account,
region,
},
});
});
});
// Create cross-region traffic management
const routeWeights = {
'us-east-1': 50,
'eu-west-1': 30,
'ap-southeast-1': 20,
};
services.forEach(service => {
// This would need to be implemented with cross-region references
// For now, showing the structure
new CrossRegionMeshStack(this, `${service.name}-mesh`, {
meshName,
serviceName: service.name,
virtualNodes: [], // Would be populated with actual virtual nodes
routeWeights,
});
});
}
}
Dockerfile
# Orders Service Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS base
WORKDIR /app
EXPOSE 80
# Create a non-root user
RUN addgroup -g 1001 -S appuser && \
adduser -S appuser -G appuser -u 1001
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /src
# Copy csproj and restore dependencies
COPY ["OrdersService.csproj", "."]
RUN dotnet restore "OrdersService.csproj"
# Copy source code
COPY . .
# Build the application
RUN dotnet build "OrdersService.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "OrdersService.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
# Copy published application
COPY --from=publish /app/publish .
# Set ownership and permissions
RUN chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/health || exit 1
ENTRYPOINT ["dotnet", "OrdersService.dll"]
# Inventory Service Dockerfile
# FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS base
# WORKDIR /app
# EXPOSE 80
# RUN addgroup -g 1001 -S appuser && \
# adduser -S appuser -G appuser -u 1001
# FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
# WORKDIR /src
# COPY ["InventoryService.csproj", "."]
# RUN dotnet restore "InventoryService.csproj"
# COPY . .
# RUN dotnet build "InventoryService.csproj" -c Release -o /app/build
# FROM build AS publish
# RUN dotnet publish "InventoryService.csproj" -c Release -o /app/publish /p:UseAppHost=false
# FROM base AS final
# WORKDIR /app
# COPY --from=publish /app/publish .
# RUN chown -R appuser:appuser /app
# USER appuser
# HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
# CMD curl -f http://localhost:80/health || exit 1
# ENTRYPOINT ["dotnet", "InventoryService.dll"]
# User Service Dockerfile
# FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS base
# WORKDIR /app
# EXPOSE 80
# RUN addgroup -g 1001 -S appuser && \
# adduser -S appuser -G appuser -u 1001
# FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
# WORKDIR /src
# COPY ["UserService.csproj", "."]
# RUN dotnet restore "UserService.csproj"
# COPY . .
# RUN dotnet build "UserService.csproj" -c Release -o /app/build
# FROM build AS publish
# RUN dotnet publish "UserService.csproj" -c Release -o /app/publish /p:UseAppHost=false
# FROM base AS final
# WORKDIR /app
# COPY --from=publish /app/publish .
# RUN chown -R appuser:appuser /app
# USER appuser
# HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
# CMD curl -f http://localhost:80/health || exit 1
# ENTRYPOINT ["dotnet", "UserService.dll"]
App Mesh Configuration
# orders-virtual-node-us-east-1.yaml
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualNode
metadata:
name: orders-service-us-east-1
namespace: default
spec:
awsName: orders-service-us-east-1
podSelector:
matchLabels:
app: orders-service
listeners:
- portMapping:
port: 80
protocol: http
healthCheck:
protocol: http
path: '/health'
healthyThreshold: 2
unhealthyThreshold: 3
timeoutMillis: 5000
intervalMillis: 30000
serviceDiscovery:
awsCloudMap:
namespaceName: internal.local
serviceName: orders
backends:
- virtualService:
virtualServiceRef:
name: inventory-service
- virtualService:
virtualServiceRef:
name: user-service
---
# orders-virtual-node-eu-west-1.yaml
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualNode
metadata:
name: orders-service-eu-west-1
namespace: default
spec:
awsName: orders-service-eu-west-1
podSelector:
matchLabels:
app: orders-service
listeners:
- portMapping:
port: 80
protocol: http
healthCheck:
protocol: http
path: '/health'
healthyThreshold: 2
unhealthyThreshold: 3
timeoutMillis: 5000
intervalMillis: 30000
serviceDiscovery:
awsCloudMap:
namespaceName: internal.local
serviceName: orders
backends:
- virtualService:
virtualServiceRef:
name: inventory-service
- virtualService:
virtualServiceRef:
name: user-service
---
# orders-virtual-node-ap-southeast-1.yaml
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualNode
metadata:
name: orders-service-ap-southeast-1
namespace: default
spec:
awsName: orders-service-ap-southeast-1
podSelector:
matchLabels:
app: orders-service
listeners:
- portMapping:
port: 80
protocol: http
healthCheck:
protocol: http
path: '/health'
healthyThreshold: 2
unhealthyThreshold: 3
timeoutMillis: 5000
intervalMillis: 30000
serviceDiscovery:
awsCloudMap:
namespaceName: internal.local
serviceName: orders
backends:
- virtualService:
virtualServiceRef:
name: inventory-service
- virtualService:
virtualServiceRef:
name: user-service
---
# virtual-router.yaml
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualRouter
metadata:
name: orders-router
namespace: default
spec:
awsName: orders-router
listeners:
- portMapping:
port: 80
protocol: http
routes:
- name: orders-route
httpRoute:
match:
prefix: /api/orders
action:
weightedTargets:
- virtualNodeRef:
name: orders-service-us-east-1
weight: 50
- virtualNodeRef:
name: orders-service-eu-west-1
weight: 30
- virtualNodeRef:
name: orders-service-ap-southeast-1
weight: 20
timeout:
idle:
unit: s
value: 30
perRequest:
unit: s
value: 10
retryPolicy:
maxRetries: 3
perRetryTimeout:
unit: s
value: 5
httpRetryEvents:
- server-error
- gateway-error
- client-error
tcpRetryEvents:
- connection-error
---
# virtual-service.yaml
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualService
metadata:
name: orders-service
namespace: default
spec:
awsName: orders.internal.local
provider:
virtualRouter:
virtualRouterRef:
name: orders-router
---
# inventory-virtual-service.yaml
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualService
metadata:
name: inventory-service
namespace: default
spec:
awsName: inventory.internal.local
provider:
virtualRouter:
virtualRouterRef:
name: inventory-router
---
# user-virtual-service.yaml
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualService
metadata:
name: user-service
namespace: default
spec:
awsName: users.internal.local
provider:
virtualRouter:
virtualRouterRef:
name: user-router
---
# mesh.yaml
apiVersion: appmesh.k8s.aws/v1beta2
kind: Mesh
metadata:
name: cross-region-mesh
spec:
awsName: cross-region-mesh
namespaceSelector:
matchLabels:
mesh: cross-region-mesh
egressFilter:
type: ALLOW_ALL
---
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: microservices
labels:
mesh: cross-region-mesh
appmesh.k8s.aws/sidecarInjectorWebhook: enabled
ECS Task Definitions
{
"family": "orders-service-task",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecsTaskRole",
"containerDefinitions": [
{
"name": "envoy",
"image": "public.ecr.aws/appmesh/aws-appmesh-envoy:v1.25.4.0-prod",
"essential": true,
"user": "1337",
"memory": 128,
"environment": [
{
"name": "APPMESH_VIRTUAL_NODE_NAME",
"value": "mesh/cross-region-mesh/virtualNode/orders-service-us-east-1"
},
{
"name": "AWS_REGION",
"value": "us-east-1"
},
{
"name": "ENABLE_ENVOY_STATS_TAGS",
"value": "1"
},
{
"name": "ENABLE_ENVOY_DOG_STATSD",
"value": "1"
}
],
"healthCheck": {
"command": [
"CMD-SHELL",
"curl -s http://localhost:9901/server_info | grep state | grep -q LIVE"
],
"interval": 5,
"timeout": 2,
"retries": 3,
"startPeriod": 10
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/orders-service",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "envoy"
}
},
"portMappings": [
{
"containerPort": 9901,
"protocol": "tcp"
}
]
},
{
"name": "orders-service",
"image": "ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/orders-service:latest",
"essential": true,
"memory": 384,
"environment": [
{
"name": "ASPNETCORE_ENVIRONMENT",
"value": "Production"
},
{
"name": "ASPNETCORE_URLS",
"value": "http://*:80"
},
{
"name": "AWS_REGION",
"value": "us-east-1"
},
{
"name": "Services__Inventory",
"value": "http://inventory.internal.local"
},
{
"name": "Services__Users",
"value": "http://users.internal.local"
}
],
"portMappings": [
{
"containerPort": 80,
"protocol": "tcp"
}
],
"dependsOn": [
{
"containerName": "envoy",
"condition": "HEALTHY"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/orders-service",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "app"
}
},
"healthCheck": {
"command": [
"CMD-SHELL",
"curl -f http://localhost:80/health || exit 1"
],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
}
},
{
"name": "xray-daemon",
"image": "public.ecr.aws/xray/aws-xray-daemon:latest",
"essential": false,
"memory": 32,
"portMappings": [
{
"containerPort": 2000,
"protocol": "udp"
}
],
"environment": [
{
"name": "AWS_REGION",
"value": "us-east-1"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/orders-service",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "xray"
}
}
}
],
"proxyConfiguration": {
"type": "APPMESH",
"containerName": "envoy",
"properties": [
{
"name": "IgnoredUID",
"value": "1337"
},
{
"name": "ProxyIngressPort",
"value": "15000"
},
{
"name": "ProxyEgressPort",
"value": "15001"
},
{
"name": "AppPorts",
"value": "80"
},
{
"name": "EgressIgnoredIPs",
"value": "169.254.170.2,169.254.169.254"
}
]
},
"tags": [
{
"key": "Environment",
"value": "production"
},
{
"key": "Service",
"value": "orders"
},
{
"key": "Region",
"value": "us-east-1"
}
]
}
More Articles
C# Programming🚀
Thank you for being a part of the C# community! Before you leave:
Follow us: **LinkedIn | Dev.to Visit our other platforms: [GitHub](https://github.com/ssukhpinder) More content at [C# Programming](https://medium.com/c-sharp-progarmming)**

메타데이터
- post_id
- d811a440e87f
- slug
- cross-region-net-microservices-with-aws-app-mesh-and-ecs-fargate-d811a440e87f
- url
- https://medium.com/c-sharp-programming/cross-region-net-microservices-with-aws-app-mesh-and-ecs-fargate-d811a440e87f
- canonical_url
- https://medium.com/c-sharp-programming/cross-region-net-microservices-with-aws-app-mesh-and-ecs-fargate-d811a440e87f
- author_url
- https://medium.com/@singhsukhpinder
- status
- ok
- fetched_at
- 2026-07-09 20:10:33