Guide · AWS ECS Fargate
Fargate Task Definition for MCP Servers
A Fargate task definition is the immutable blueprint for your MCP server container — CPU allocation, memory limits, secrets injection, log routing, and signal handling are all set here. Most mistakes are invisible at startup but cause production failures: wrong CPU/memory combination — Fargate rejects invalid combinations at registration time, not at deploy time, with a cryptic InvalidParameterException; no init process (initProcessEnabled: true) — MCP servers that spawn child processes (shell commands, subprocess tool calls) accumulate zombie processes as PID 1 cannot reap them without init; default nofile ulimit of 1024 — an MCP server handling 100 concurrent sessions will exhaust file descriptors on the default limit because each WebSocket connection, database connection, and HTTP keepalive consumes one descriptor. These are the details that don't surface in tutorials but cause real production incidents.
TL;DR
Pick a valid CPU/memory combination from the Fargate table. Enable initProcessEnabled: true. Set nofile softLimit: 65536, hardLimit: 65536. Inject secrets from Secrets Manager via the secrets array (not environment variables). Use awslogs log driver with explicit log group name and 30-day retention. Set ephemeralStorage: 21 GiB minimum if your MCP tools write temp files.
Valid CPU and memory combinations
Fargate does not allow arbitrary CPU/memory values. The valid combinations are fixed — registering a task definition outside these values fails at registration time:
| CPU (units) | CPU (vCPU) | Valid memory range (MiB) | MCP server fit |
|---|---|---|---|
| 256 | 0.25 vCPU | 512–2048 (in 512 increments) | Development only; single-digit concurrent tool calls |
| 512 | 0.5 vCPU | 1024–4096 (in 1024 increments) | Low-traffic servers; under 20 concurrent tool calls |
| 1024 | 1 vCPU | 2048–8192 (in 1024 increments) | Standard production MCP servers; up to 50 concurrent calls |
| 2048 | 2 vCPU | 4096–16384 (in 1024 increments) | CPU-intensive tool calls (code execution, image processing) |
| 4096 | 4 vCPU | 8192–30720 (in 1024 increments) | Memory-heavy MCP servers (in-memory caches, large datasets) |
| 8192 | 8 vCPU | 16384–61440 (in 4096 increments) | High-throughput servers or multi-process MCP daemons |
| 16384 | 16 vCPU | 32768–122880 (in 8192 increments) | Maximum Fargate size; batch-processing MCP tools |
Memory is specified at the task level. Individual containers within the task can have memoryReservation (soft limit) and memory (hard limit) values that sum to at most the task-level memory. ECS OOM-kills a container that exceeds its hard memory limit; if no hard limit is set, it can consume all task memory and OOM-kill the task.
Complete task definition JSON
A production-ready task definition for a Node.js MCP server:
{
"family": "mcp-server",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "1024",
"memory": "2048",
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::123456789012:role/mcpServerTaskRole",
"ephemeralStorage": { "sizeInGiB": 21 },
"containerDefinitions": [{
"name": "mcp-server",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-server:v1.2.3",
"essential": true,
"initProcessEnabled": true,
"cpu": 900,
"memoryReservation": 1536,
"memory": 1920,
"portMappings": [{
"name": "http",
"containerPort": 3000,
"protocol": "tcp",
"appProtocol": "http"
}],
"environment": [
{ "name": "NODE_ENV", "value": "production" },
{ "name": "PORT", "value": "3000" },
{ "name": "LOG_LEVEL", "value": "warn" }
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/mcp-server/database-AbCdEf:connection_string::"
},
{
"name": "API_KEY",
"valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/mcp-server/api-key"
}
],
"ulimits": [{
"name": "nofile",
"softLimit": 65536,
"hardLimit": 65536
}],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
"interval": 10,
"timeout": 5,
"retries": 3,
"startPeriod": 30
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/mcp-server",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "mcp-server"
}
}
}]
}
Init process and zombie reaping
Linux PID 1 is responsible for reaping zombie processes — child processes that have finished execution but whose exit status has not been collected by their parent. In a container without an init process, your MCP server is PID 1 but Node.js (and most language runtimes) do not implement zombie reaping. MCP tools that spawn subprocesses — shell commands, Python scripts, data processing scripts — accumulate zombie entries in /proc over time. Under high load, this exhausts process table slots:
# Symptom: eventually fails with
# Error: EAGAIN: resource temporarily unavailable, fork
# Fix: enable the init process in the container definition
"initProcessEnabled": true
# This adds a minimal init process (tini) as PID 1 that:
# 1. Forwards signals (SIGTERM, SIGINT) to your application
# 2. Reaps zombie child processes automatically
# Adds ~100KB of overhead; negligible.
Signal forwarding is the second reason initProcessEnabled matters. Without it, the SIGTERM that ECS sends during scale-in or deployment goes to PID 1 (your Node.js process), but child processes spawned by child_process.spawn() run in their own process groups and do not receive the signal — they continue running until the container is force-killed after the 30-second grace period.
File descriptor limits for connection-heavy MCP servers
The default nofile ulimit in Fargate containers is 1024. An MCP server under moderate load can easily exhaust this:
| Resource type | File descriptors consumed | Notes |
|---|---|---|
| WebSocket connection | 1 per client | MCP uses SSE or WebSocket — each session = 1 persistent connection |
| HTTP/1.1 keepalive to downstream API | 1–5 per upstream service | Connection pool per service |
| Database connection pool | 10–50 per instance | pg, mysql2, better-sqlite3 each consume descriptors |
| Node.js internals (stdio, IPC, libuv) | ~20–30 | Fixed overhead |
With 100 concurrent WebSocket sessions + a database pool of 20 + 5 upstream connections + 30 Node internals = 155 descriptors — well under 1024. But under burst load (200 concurrent sessions) or with a connection leak, the default limit is reachable. Set both soft and hard limits to 65536 as a precaution:
"ulimits": [{
"name": "nofile",
"softLimit": 65536,
"hardLimit": 65536
}]
Ephemeral storage
Fargate tasks get 20 GiB of ephemeral storage by default, shared between the container image layers and any writable storage. If your MCP tools write temporary files (file processing, code execution sandbox files, downloaded assets), you may need to increase this:
"ephemeralStorage": { "sizeInGiB": 50 }
Valid range is 21–200 GiB. The first 20 GiB are free; additional storage costs $0.10/GB/month (prorated to the task's runtime).
For MCP tools that write large temp files and then delete them, use /tmp inside the container — it writes to ephemeral storage. If the tool writes files that must persist across task restarts, use an EFS mount instead:
{
"volumes": [{
"name": "mcp-persistent-data",
"efsVolumeConfiguration": {
"fileSystemId": "fs-abc12345",
"rootDirectory": "/mcp-server",
"transitEncryption": "ENABLED",
"authorizationConfig": { "accessPointId": "fsap-abc123", "iam": "ENABLED" }
}
}],
"containerDefinitions": [{
"mountPoints": [{
"sourceVolume": "mcp-persistent-data",
"containerPath": "/app/data",
"readOnly": false
}]
}]
}
Secrets injection from Secrets Manager and SSM
Inject secrets via the secrets array — not via environment. The secrets array fetches values from Secrets Manager or SSM Parameter Store at task startup and injects them as environment variables. Values in environment are stored in plain text in the task definition and visible to anyone with ECS read access:
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/mcp-server/database-AbCdEf:connection_string::"
}
]
# The valueFrom format for Secrets Manager:
# arn:...:secret:SECRET_NAME:JSON_KEY::
# JSON_KEY extracts a single key from a JSON secret.
# Leave JSON_KEY empty to inject the entire secret string.
# For SSM Parameter Store:
{
"name": "API_KEY",
"valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/mcp-server/api-key"
}
The execution role (not the task role) needs permissions to fetch secrets:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/mcp-server/*"
},
{
"Effect": "Allow",
"Action": "ssm:GetParameters",
"Resource": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/mcp-server/*"
},
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:us-east-1:123456789012:key/your-key-id"
}
]
}
Common failures
| Symptom | Root cause | Fix |
|---|---|---|
InvalidParameterException: Memory value … is not supported when registering task definition |
CPU/memory combination outside the valid Fargate table | Check the valid combinations table; e.g., 1024 CPU requires memory between 2048–8192 in 1024 increments |
| MCP server accumulates zombie processes over hours, eventually fails to fork new processes | initProcessEnabled not set; Node.js as PID 1 cannot reap zombies from spawned child processes |
Add "initProcessEnabled": true to the container definition; redeploy |
Task fails to start: CannotPullContainerError: Error response from daemon: toomanyrequests |
Execution role lacks permission to authenticate to ECR; pulling from public ECR without authentication hits rate limits | Add ecr:GetAuthorizationToken, ecr:BatchGetImage, ecr:GetDownloadUrlForLayer to the execution role |
Task fails to start: ResourceInitializationError: unable to pull secrets |
Execution role lacks secretsmanager:GetSecretValue or ssm:GetParameters; or the VPC has no route to the Secrets Manager endpoint |
Add required IAM permissions to execution role; add VPC endpoint for secretsmanager or ssm |
MCP tool calls fail with EMFILE: too many open files |
Default nofile ulimit of 1024 exhausted by concurrent connections and file operations |
Set ulimits nofile softLimit: 65536, hardLimit: 65536 in the container definition |
| Container writes to disk succeed but files disappear on task restart | Writing to ephemeral storage which is not persistent across task restarts | For persistent storage, mount an EFS volume; ephemeral storage only survives the task's lifetime |
Task definition mistakes cause silent MCP server failures
File descriptor exhaustion, zombie process buildup, and secrets injection failures all cause MCP tool calls to silently fail without crashing the container. AliveMCP probes every 60 seconds — you get alerted when the server becomes unresponsive, before the issue grows into a full outage.
Join the waitlist →