Guide · AWS Core Services
MCP Server ECS — Fargate deployment, task roles, health checks, rolling updates
Amazon ECS Fargate is the simplest AWS path for running MCP servers in production without managing EC2 instances: you provide a Docker image and a task definition, ECS schedules containers on managed infrastructure, and an Application Load Balancer routes HTTP/S traffic to healthy containers. The three role/configuration traps that affect almost every first ECS deployment are: execution role vs task role confusion (the execution role is used by the ECS agent to pull your image and ship logs — your application code uses the task role; missing task role permissions produce 403s inside running containers, while missing execution role permissions prevent the container from starting at all), ALB health check misconfiguration (the ALB marks the target unhealthy and the task is killed and replaced in a loop if the health check path returns non-200 or takes longer than the timeout), and secrets vs environment variables (secrets injected via SSM Parameter Store or Secrets Manager appear as environment variables at container start — they are not refreshed during the container's lifetime; a rotated secret requires a task replacement).
TL;DR
Define two IAM roles: execution role (ECR pull + CloudWatch Logs) and task role (your application's AWS permissions). Set the ALB health check path to /health (not /) and make it return HTTP 200 in under 5 seconds. Inject secrets via secrets in the task definition (not environment) to avoid them appearing in CloudFormation/CDK plaintext. Set minimumHealthyPercent: 100 and maximumPercent: 200 for zero-downtime rolling deploys.
Task definition: the two IAM roles
Every ECS task definition references two distinct IAM roles. Confusing them is the #1 ECS mistake for MCP server developers new to the platform.
| Role | Used by | What it needs | Missing permission symptom |
|---|---|---|---|
| Execution role | ECS agent (not your code) | ECR pull, CloudWatch Logs write, SSM/Secrets Manager read (for injecting secrets) | Container fails to start; task stuck in PROVISIONING or shows "CannotPullContainerError" |
| Task role | Your application code | Any AWS service your MCP tools call: S3, SQS, DynamoDB, SNS, etc. | Container starts and runs; tool calls fail with 403 AccessDenied at runtime |
// AWS CDK: ECS Fargate task definition with both roles
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as logs from "aws-cdk-lib/aws-logs";
// Execution role: only the ECS agent uses this
const executionRole = new iam.Role(this, "ExecutionRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AmazonECSTaskExecutionRolePolicy"),
// ^^ includes ecr:GetAuthorizationToken, ecr:BatchGetImage, logs:CreateLogStream, logs:PutLogEvents
],
});
// Task role: your MCP server's application code uses this for AWS API calls
const taskRole = new iam.Role(this, "TaskRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
});
taskRole.addToPolicy(new iam.PolicyStatement({
actions: ["sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage", "s3:GetObject"],
resources: ["*"], // narrow to specific ARNs in production
}));
const logGroup = new logs.LogGroup(this, "LogGroup", {
logGroupName: "/ecs/mcp-server",
retention: logs.RetentionDays.ONE_MONTH,
});
const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef", {
cpu: 512, // 0.5 vCPU
memoryLimitMiB: 1024,
executionRole,
taskRole,
});
taskDefinition.addContainer("McpServer", {
image: ecs.ContainerImage.fromEcrRepository(ecrRepo, "latest"),
portMappings: [{ containerPort: 3000 }],
logging: ecs.LogDrivers.awsLogs({
streamPrefix: "ecs",
logGroup,
}),
});
Secrets injection: SSM and Secrets Manager
Never put secrets in the environment array of a task definition — they appear as plaintext in CloudFormation templates and the ECS console. Use the secrets array instead: ECS retrieves the value from SSM Parameter Store or Secrets Manager at task start and injects it as an environment variable, and the execution role (not task role) needs permission to read the secret.
// CDK: inject secrets from SSM and Secrets Manager
import * as ssm from "aws-cdk-lib/aws-ssm";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
const dbPasswordSecret = secretsmanager.Secret.fromSecretNameV2(this, "DbPassword",
"mcp-server/db-password");
const apiKeyParam = ssm.StringParameter.fromStringParameterName(this, "ApiKey",
"/mcp-server/api-key");
// Grant execution role permission to read these
dbPasswordSecret.grantRead(executionRole);
apiKeyParam.grantRead(executionRole);
taskDefinition.addContainer("McpServer", {
image: ecs.ContainerImage.fromEcrRepository(ecrRepo, "latest"),
environment: {
NODE_ENV: "production", // non-secret config — fine in environment
PORT: "3000",
AWS_REGION: "us-east-1",
},
secrets: {
// These become environment variables in the container (DB_PASSWORD, API_KEY)
// Values fetched at task start; NOT refreshed during container lifetime
DB_PASSWORD: ecs.Secret.fromSecretsManager(dbPasswordSecret),
API_KEY: ecs.Secret.fromSsmParameter(apiKeyParam),
},
});
Secrets are fetched at task start and are not refreshed while the container runs. Rotating a secret requires redeploying (stopping and starting tasks). If a secret is rotated, running tasks continue using the old value until replaced. For short-rotation secrets, prefer fetching from SDK inside the application at call time rather than at startup.
Health check: ALB vs container health check
ECS has two independent health check mechanisms: the ALB target group health check (HTTP request to the container, decides whether to route traffic) and the container health check in the task definition (runs a command inside the container, decides whether to restart it). They serve different purposes and must both be configured correctly.
// Health endpoint in your MCP HTTP server (Express example)
// Must return 200 in < ALB timeout (typically 5 seconds)
app.get("/health", (_req, res) => {
// Keep this fast — no database queries, no external calls
// The ALB will mark the task unhealthy if this takes > timeoutSeconds
res.status(200).json({
status: "ok",
uptime: process.uptime(),
version: process.env.SERVICE_VERSION,
});
});
// CDK: ALB target group with health check
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as cdk from "aws-cdk-lib";
const targetGroup = new elbv2.ApplicationTargetGroup(this, "TargetGroup", {
vpc,
port: 3000,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: elbv2.TargetType.IP,
healthCheck: {
path: "/health", // MUST be a dedicated fast endpoint — never "/"
healthyThresholdCount: 2, // 2 consecutive 200s = healthy
unhealthyThresholdCount: 3,// 3 consecutive non-200s = unhealthy → task replaced
interval: cdk.Duration.seconds(30),
timeout: cdk.Duration.seconds(5), // health check must respond in 5s
healthyHttpCodes: "200",
},
});
// CDK: container health check (separate from ALB health check)
taskDefinition.addContainer("McpServer", {
// ...
healthCheck: {
command: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
interval: cdk.Duration.seconds(30),
timeout: cdk.Duration.seconds(5),
retries: 3,
startPeriod: cdk.Duration.seconds(60), // grace period for slow startup
},
});
Rolling deployment: zero-downtime updates
ECS rolling deployments start new tasks, wait until they are healthy (both ALB and container health checks pass), then stop old tasks. The minimumHealthyPercent and maximumPercent service settings control how many tasks run during the transition.
// CDK: ECS service with rolling deployment configuration
const service = new ecs.FargateService(this, "McpService", {
cluster,
taskDefinition,
desiredCount: 2,
assignPublicIp: false, // private subnet; ALB routes traffic inbound
deploymentController: {
type: ecs.DeploymentControllerType.ECS, // rolling; not CODE_DEPLOY (blue/green)
},
circuitBreaker: {
rollback: true, // auto-rollback if deployment fails health checks
},
minHealthyPercent: 100, // never go below 2 healthy tasks during deployment
maxHealthyPercent: 200, // allow up to 4 tasks during transition (2 old + 2 new)
});
service.attachToApplicationTargetGroup(targetGroup);
// Deployment flow with these settings on a 2-task service:
// 1. Start 2 new tasks (total = 4, max 200% of desired = 4)
// 2. Wait until both new tasks pass health checks
// 3. Stop 2 old tasks (total = 2, min 100% of desired = 2)
// No request is lost — ALB drains connections before stopping old tasks
Connection draining: when ECS stops a task, it first deregisters the target from the ALB and waits for the deregistration delay (default 300 seconds) before stopping the container. This allows in-flight requests to complete. For MCP servers with long-running tool calls, increase the deregistration delay or add a SIGTERM handler that waits for active requests before exiting.
SIGTERM handler for graceful shutdown
// Handle SIGTERM (sent by ECS when stopping the task)
// Give in-flight MCP tool calls up to 25 seconds to complete
// (ECS force-kills with SIGKILL after 30 seconds)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "http";
const server = new McpServer({ name: "mcp-server", version: "1.0.0" });
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const httpServer = createServer(transport.requestHandler);
process.on("SIGTERM", async () => {
console.log(JSON.stringify({ level: "INFO", event: "shutdown.started" }));
// Stop accepting new connections
httpServer.close(() => {
console.log(JSON.stringify({ level: "INFO", event: "shutdown.http_server_closed" }));
});
// Allow up to 25 seconds for in-flight requests to complete
// ECS sends SIGKILL after 30 seconds (stopTimeout: 30000 in task definition)
setTimeout(() => {
console.log(JSON.stringify({ level: "INFO", event: "shutdown.timeout" }));
process.exit(0);
}, 25_000);
});
await server.connect(transport);
httpServer.listen(3000);
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Task stuck in PROVISIONING, then fails | Execution role missing ECR pull permissions or log group doesn't exist | Attach AmazonECSTaskExecutionRolePolicy to execution role; pre-create log group |
| Container starts then is immediately killed | ALB health check failing (wrong path, non-200, or timeout) | Implement GET /health → 200 responding in <5 seconds; set correct health check path in target group |
| MCP tool calls fail with 403 AccessDenied | AWS API permission missing on task role (not execution role) | Add the required action to the task role IAM policy |
| Secrets not injected (env var undefined) | Secret ARN/name wrong, or execution role missing secretsmanager:GetSecretValue | Check task stop reason in ECS console; grant execution role read permission on the secret |
| Deployment loops: old tasks never stop | New tasks fail health checks; ECS rolls back; minHealthyPercent prevents stopping old tasks | Fix the health check first; check new task logs; enable circuit breaker with rollback |
| In-flight requests dropped during deploy | Deregistration delay too short (default 300s may be reduced) | Keep deregistration delay at 300s or add SIGTERM handler with setTimeout |
| Rotated secret not picked up | Secrets injected at task start; running containers use the old value | Force a new deployment after rotation: aws ecs update-service --force-new-deployment |