Home β€Ί .NET on AWS β€Ί Running .NET on ECS and Fargate

Running .NET on ECS and Fargate

Containerizing .NET applications on AWS: ECS, Fargate, health checks, service discovery, and CDK setup.

When to Use Containers over Lambda

Lambda is great for event-driven, short-lived work. But some .NET workloads are better served by containers:

  • Long-running processes (background workers, queue consumers that hold connections open)
  • Large memory/CPU requirements (Lambda caps at 10GB RAM, 6 vCPU)
  • Existing ASP.NET Core applications that you want to lift to AWS without refactoring
  • WebSocket connections or long-lived HTTP streams
  • Workloads with predictable, steady traffic where Lambda's per-invocation pricing is more expensive than always-on compute

ECS vs EKS

ECS/Fargate EKS
Complexity Medium High
Control Good Full
Cost at scale Good Best (with Karpenter)
Best for Most .NET apps Kubernetes shops
.NET fit Excellent Good

For most .NET teams, ECS with Fargate is the sweet spot: no cluster management, no nodes to patch, good integration with ALB and service discovery.

Dockerfile for .NET

Standard multi-stage build

# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Restore first (layer caching)
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
COPY ["src/MyDomain/MyDomain.csproj", "src/MyDomain/"]
RUN dotnet restore "src/MyApi/MyApi.csproj"

# Build
COPY . .
RUN dotnet publish "src/MyApi/MyApi.csproj" -c Release -o /app/publish --no-restore

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
WORKDIR /app
EXPOSE 8080

# Non-root user
USER $APP_UID

COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]

AoT container (smallest possible image)

# Build stage with full SDK
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
RUN apt-get update && apt-get install -y clang zlib1g-dev
WORKDIR /src

COPY . .
RUN dotnet publish "src/MyApi/MyApi.csproj" -c Release -r linux-x64 --self-contained -o /app/publish

# Runtime stage β€” minimal base image
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS runtime
WORKDIR /app
EXPOSE 8080
USER $APP_UID

COPY --from=build /app/publish/MyApi .
ENTRYPOINT ["./MyApi"]

The AoT image uses runtime-deps (no .NET runtime needed) and can be as small as 30-50MB.

Health Checks

ECS needs health checks to know when your container is ready and when to replace unhealthy instances:

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
    .AddCheck("db", () =>
    {
        // Check database connectivity
        return HealthCheckResult.Healthy();
    })
    .AddCheck("cache", () =>
    {
        // Check Redis/Valkey
        return HealthCheckResult.Healthy();
    });

var app = builder.Build();
app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready"),
});
app.Run();

Configure the port: ECS Fargate doesn't use port 80 by default for non-root users:

// Program.cs or appsettings.json
builder.WebHost.UseUrls("http://+:8080");

Service Discovery

For microservices that need to find each other without going through an ALB:

// Use AWS Cloud Map for service discovery
// Services register automatically, others resolve via DNS
// e.g., order-service.my-namespace β†’ private IP of the task

CDK Setup (C#)

Fargate service with ALB

using Amazon.CDK;
using Amazon.CDK.AWS.ECS;
using Amazon.CDK.AWS.ECS.Patterns;
using Amazon.CDK.AWS.EC2;

var cluster = new Cluster(this, "AppCluster", new ClusterProps
{
    Vpc = vpc,
    ContainerInsights = true,
});

// ALB-backed Fargate service
var service = new ApplicationLoadBalancedFargateService(this, "OrderApi", 
    new ApplicationLoadBalancedFargateServiceProps
{
    Cluster = cluster,
    DesiredCount = 2,
    TaskImageOptions = new ApplicationLoadBalancedTaskImageOptions
    {
        Image = ContainerImage.FromAsset("./src/OrderApi"),
        ContainerPort = 8080,
        Environment = new Dictionary<string, string>
        {
            ["ASPNETCORE_URLS"] = "http://+:8080",
            ["TABLE_NAME"] = table.TableName,
        },
    },
    Cpu = 512,
    MemoryLimitMiB = 1024,
    RuntimePlatform = new RuntimePlatform
    {
        CpuArchitecture = CpuArchitecture.ARM64,
        OperatingSystemFamily = OperatingSystemFamily.LINUX,
    },
    HealthCheck = new Amazon.CDK.AWS.ECS.HealthCheck
    {
        Command = new[] { "CMD-SHELL", "curl -f http://localhost:8080/health || exit 1" },
        Interval = Duration.Seconds(30),
        Timeout = Duration.Seconds(5),
        Retries = 3,
    },
    CircuitBreaker = new DeploymentCircuitBreaker { Rollback = true },
});

// Auto-scaling
var scaling = service.Service.AutoScaleTaskCount(new EnableScalingProps
{
    MinCapacity = 2,
    MaxCapacity = 10,
});

scaling.ScaleOnCpuUtilization("CpuScaling", new CpuUtilizationScalingProps
{
    TargetUtilizationPercent = 70,
    ScaleInCooldown = Duration.Seconds(60),
    ScaleOutCooldown = Duration.Seconds(60),
});

// Grant DynamoDB access
table.GrantReadWriteData(service.TaskDefinition.TaskRole);

Background worker (no ALB)

var workerService = new QueueProcessingFargateService(this, "OrderWorker",
    new QueueProcessingFargateServiceProps
{
    Cluster = cluster,
    Queue = orderQueue,
    Image = ContainerImage.FromAsset("./src/OrderWorker"),
    Cpu = 256,
    MemoryLimitMiB = 512,
    MinScalingCapacity = 1,
    MaxScalingCapacity = 5,
    Environment = new Dictionary<string, string>
    {
        ["TABLE_NAME"] = table.TableName,
    },
});

Graceful Shutdown

ECS sends SIGTERM before stopping a task. Handle it properly:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();

lifetime.ApplicationStopping.Register(() =>
{
    // Finish in-flight requests, close connections
    Log.Information("Shutting down gracefully...");
    // Give ALB time to drain connections
    Thread.Sleep(TimeSpan.FromSeconds(10));
});

app.Run();

Set the ECS task stop timeout to give your app time to drain:

TaskDefinition = new FargateTaskDefinition(this, "TaskDef", new FargateTaskDefinitionProps
{
    // ...
});
// Default stop timeout is 30 seconds β€” sufficient for most apps

Tips

  • Use arm64 (Graviton) tasks: 20% cheaper than x86, same or better performance for .NET.
  • Always use multi-stage Docker builds. The SDK image is 800MB+. The runtime image is ~100MB. Alpine variants are smaller still.
  • Health check endpoints should be fast. Don't do deep dependency checks on the liveness probe. Just verify the process is responding. Use readiness probes for dependency checks.
  • Set proper resource limits. A .NET API typically runs fine at 512 CPU / 1024MB. Don't over-provision. You're paying for it.
  • Use deployment circuit breaker. If new tasks fail health checks, ECS automatically rolls back to the last working version.
  • ECR for container images. Store your images in ECR (same region as ECS) for fastest pulls and no cross-region transfer costs.

Further Reading

Looking for hands-on help? View my .NET on AWS services β†’

Containerizing your .NET apps?

Drop me a message β€” I typically respond within one business day.