ECS Architecture & Deployment for .NET

Overview

This guide outlines the blueprint for containerizing a .NET 8/9 application and running it on Amazon ECS, breaking down the core differences between the Fargate and EC2 launch types using infrastructure-as-code and native JSON configurations.


🏗️ 1. Architecture Flow: ECS Control Plane vs. Data Plane

graph TD
    User([Client / Browser]) --> ALB[Application Load Balancer]
    
    subgraph AWS Cloud [AWS Region]
        ALB --> TargetGroup[Target Group :8080]
        
        subgraph ECS Cluster ["ECS Cluster (Managed Control Plane)"]
            subgraph FargatePath [Launch Type: FARGATE]
                FTask1["Task: .NET API Container"]:::dotnet
                FTask2["Task: .NET API Container"]:::dotnet
            end
            
            subgraph EC2Path [Launch Type: EC2]
                EC2Host["EC2 Instance (t3.medium)"] --> EC2Task1["Task: .NET Container"]:::dotnet
                EC2Host --> EC2Task2["Task: .NET Container"]:::dotnet
            end
        end
    end

    TargetGroup --> FTask1 & FTask2 & EC2Task1

    classDef dotnet fill:#512BD4,stroke:#fff,stroke-width:2px,color:#fff;

🐳 2. The Foundation: Production .NET Dockerfile

Before deploying to ECS, your .NET application needs a multi-stage container build optimized for Linux containers.

# Stage 1: Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApi.csproj", "./"]
RUN dotnet restore "MyApi.csproj"
COPY . .
RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
 
# Stage 2: Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
 
# Create a non-root user for security best practices
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app
USER appuser
 
COPY --from=build /app/publish .
 
# Expose modern default port for ASP.NET
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
 
ENTRYPOINT ["dotnet", "MyApi.dll"]
 

📝 3. The Blueprint: ECS Task Definition (JSON)

An ECS Task Definition is your application’s blueprint. It tells ECS what Docker image to pull, how much CPU/Memory to allocate, and how to map ports.

Universal Compatibility

This exact Task Definition schema works for both Fargate and EC2 launch types. The difference is dictated solely by how you provision the service/cluster, not the task definition itself.

{
  "family": "dotnet-api-task",
  "networkMode": "awsvpc",
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::123456789012:role/MyDotNetAppApplicationRole",
  "containerDefinitions": [
    {
      "name": "dotnet-api",
      "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-dotnet-api:latest",
      "essential": true,
      "portMappings": [
        {
          "containerPort": 8080,
          "hostPort": 8080,
          "protocol": "tcp"
        }
      ],
      "environment": [
        {
          "name": "ASPNETCORE_ENVIRONMENT",
          "value": "Production"
        },
        {
          "name": "DOTNET_GC_HeapHardLimitPercent",
          "value": "75"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/dotnet-api",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ]
}

Critical .NET Setting on Fargate

Setting DOTNET_GC_HeapHardLimitPercent is essential on Fargate. Because Fargate tasks have strict, unyielding memory ceilings, letting the .NET Garbage Collector dynamically consume host memory can cause a sudden OOM (Out of Memory) crash.


⚙️ 4. Infrastructure Provisioning (Terraform Code)

Here is how you provision the compute layer in Terraform, illustrating the switch between Fargate and EC2.

Option A: ECS Service using Fargate (Serverless)

resource "aws_ecs_service" "dotnet_fargate_service" {
  name            = "dotnet-api-fargate"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 2
  launch_type     = "FARGATE"
 
  network_configuration {
    subnets          = data.aws_subnets.private.ids
    security_groups  = [aws_security_group.ecs_tasks.id]
    assign_public_ip = false
  }
 
  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "dotnet-api"
    container_port   = 8080
  }
}
 

Option B: ECS Service using EC2 (Managed Instances)

To use EC2, your cluster must have registered EC2 container instances running the ecs-agent, and your service launch type changes:

resource "aws_ecs_service" "dotnet_ec2_service" {
  name            = "dotnet-api-ec2"
  cluster         = aws_ecs_cluster.ec2_cluster.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 4
  launch_type     = "EC2"
  
  # Placement strategies can be defined for EC2 instances
  ordered_placement_strategy {
    type  = "spread"
    field = "attribute:ecs.availability-zone"
  }
 
  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "dotnet-api"
    container_port   = 8080
  }
}
 

⚖️ Summary Comparison Table for your Knowledge Base

Feature / DimensionECS on FargateECS on EC2
Compute ManagementFully serverless (AWS manages underlying VMs).You manage, patch, and size EC2 instances.
Scaling DynamicsSpun up via software invocation; cold starts take ~30s.Instantly scaled if EC2 warm pool capacity exists.
Cost ProfilePay-per-second based strictly on task resource configuration.Pay for running instances 24/7 (cheaper if utilization is high/steady).
CustomizationLow (cannot modify kernel parameters or install host agents).Absolute root access to host OS and storage layers.