Guide · AWS DevOps
MCP Server ECR — Amazon ECR image lifecycle, cross-account pull, tag immutability, vulnerability scanning
Amazon Elastic Container Registry (ECR) is the standard image store for MCP servers deployed on ECS Fargate or EKS. Three ECR failure modes hit almost every first deployment: the GetAuthorizationToken IAM trap (ecr:GetAuthorizationToken must be granted on Resource: "*" — not on a specific repository ARN — because it is an account-level API call that returns a registry-wide auth token; granting it only on the repository ARN silently denies the Docker login step and produces "no basic auth credentials" or "denied" during image pull), 12-hour token expiration (the ECR auth token returned by GetAuthorizationToken expires after 12 hours — long-running CI systems or ECS task definitions that cache the token must re-authenticate before the expiry window), and region-specific endpoints (ECR login is per-region; authenticating to us-east-1.dkr.ecr.amazonaws.com and then pulling from a us-west-2 repository fails with "not authorized"; ECS tasks in one region cannot pull from a repository in another region without cross-region replication).
TL;DR
Grant ecr:GetAuthorizationToken on Resource: "*" (not a specific repo ARN). Re-authenticate every 12 hours in CI. Enable tag immutability and scanOnPush in every production repository. Add a lifecycle policy to keep the last 10 tagged images and delete untagged images older than 1 day — without a lifecycle policy, ECR storage costs accumulate unboundedly.
IAM: the GetAuthorizationToken resource trap
ECR IAM permissions split into two categories with different resource scope requirements — getting this wrong is the most common ECS deployment failure for MCP server teams.
| Permission | Resource scope | Why |
|---|---|---|
ecr:GetAuthorizationToken | * (must be wildcard) | Account-level API — returns a single token for all repositories in the registry; no repository ARN is involved |
ecr:BatchGetImage | specific repo ARN | Per-image pull — scope to the specific repository |
ecr:GetDownloadUrlForLayer | specific repo ARN | Per-layer download — same ARN as BatchGetImage |
ecr:InitiateLayerUpload, ecr:UploadLayerPart, ecr:CompleteLayerUpload, ecr:PutImage | specific repo ARN | Push permissions — only CI role needs these; ECS task execution role does not |
// CDK: minimal ECR permissions for ECS execution role (pull-only)
executionRole.addToPolicy(new iam.PolicyStatement({
actions: [
"ecr:GetAuthorizationToken", // MUST be "*" — account-level call
],
resources: ["*"],
}));
executionRole.addToPolicy(new iam.PolicyStatement({
actions: [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
],
resources: [repository.repositoryArn], // scoped to specific repo
}));
// CDK: push permissions for CI role
ciRole.addToPolicy(new iam.PolicyStatement({
actions: [
"ecr:GetAuthorizationToken",
],
resources: ["*"],
}));
ciRole.addToPolicy(new iam.PolicyStatement({
actions: [
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage",
"ecr:BatchCheckLayerAvailability",
],
resources: [repository.repositoryArn],
}));
The AmazonECSTaskExecutionRolePolicy AWS managed policy already includes the correct ECR pull permissions with the correct resource scope. Attaching this managed policy to the execution role is safer than writing these grants manually.
Repository configuration: lifecycle policies and tag immutability
An ECR repository without a lifecycle policy accumulates images indefinitely. Every push creates new layers and a new manifest — even if you only push to :latest, the old untagged manifest remains and is billed at $0.10/GB-month.
// CDK: ECR repository with lifecycle policy and tag immutability
import * as ecr from "aws-cdk-lib/aws-ecr";
const repository = new ecr.Repository(this, "McpServerRepo", {
repositoryName: "mcp-server",
imageTagMutability: ecr.TagMutability.IMMUTABLE, // prevent overwriting tags
imageScanOnPush: true, // BasicScanning; use EnhancedScanning for Inspector
removalPolicy: RemovalPolicy.RETAIN, // don't delete repo on stack teardown
lifecycleRules: [
{
// Keep last 10 tagged releases (prefixed with "v" or "release-")
rulePriority: 1,
description: "Keep last 10 release images",
tagStatus: ecr.TagStatus.TAGGED,
tagPrefixList: ["v", "release-"],
maxImageCount: 10,
},
{
// Delete untagged images after 1 day (pushed by :latest overwrites)
rulePriority: 2,
description: "Delete untagged images after 1 day",
tagStatus: ecr.TagStatus.UNTAGGED,
maxImageAge: Duration.days(1),
},
{
// Cap any remaining tagged images at 50 total
rulePriority: 3,
description: "Hard cap at 50 images",
tagStatus: ecr.TagStatus.ANY,
maxImageCount: 50,
},
],
});
Tag immutability: once enabled, pushing a tag that already exists (e.g., :v1.2.3) returns ImageAlreadyExistsException. This is correct for production (prevents silent overwrites) but breaks workflows that push :latest on every build — those workflows must be updated to push git-SHA tags instead of :latest.
Cross-account pull
When your MCP server ECS cluster is in a different AWS account from the ECR repository (common in multi-account setups where images are built in a "tools" account and deployed to "prod"), cross-account pull requires two changes: a repository resource policy on the ECR side, and ecr:GetAuthorizationToken on the pulling account's execution role.
// CDK in the ECR (tools) account: grant cross-account pull
repository.addToResourcePolicy(new iam.PolicyStatement({
sid: "CrossAccountPull",
principals: [
new iam.ArnPrincipal(
"arn:aws:iam::PROD_ACCOUNT_ID:role/EcsTaskExecutionRole"
),
],
actions: [
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchCheckLayerAvailability",
],
}));
// NOTE: ecr:GetAuthorizationToken is NOT in the resource policy
// because it is an account-level API — the prod account's execution
// role must have ecr:GetAuthorizationToken on "*" in its own IAM policy.
// The resource policy only covers repository-level actions.
Cross-account pull does NOT work across regions. If the repository is in us-east-1 and the ECS cluster is in eu-west-1, you must either replicate the repository (ECR replication rules) or push the image to a repository in each region.
Vulnerability scanning
ECR offers two scanning modes. BasicScanning (enabled via imageScanningConfiguration.scanOnPush) is free and uses the open-source Clair scanner against the OS package database — it does not scan application dependencies (npm, pip, Maven). EnhancedScanning uses Amazon Inspector and covers OS packages plus language-level dependencies (Node.js package.json, Python requirements.txt, Java POMs) — it costs $0.11/image/month per image actively stored.
// AWS CLI: enable EnhancedScanning on a registry (account-level)
aws ecr put-registry-scanning-configuration \
--scan-type ENHANCED \
--rules '[{"repositoryFilters":[{"filter":"mcp-server*","filterType":"WILDCARD"}],"scanFrequency":"SCAN_ON_PUSH"}]'
// AWS CLI: get scan findings for the latest image
aws ecr describe-image-scan-findings \
--repository-name mcp-server \
--image-id imageTag=v1.2.3
// Block deploy on CRITICAL findings via CI script
FINDINGS=$(aws ecr describe-image-scan-findings \
--repository-name mcp-server \
--image-id imageTag=$IMAGE_TAG \
--query 'imageScanFindings.findingSeverityCounts.CRITICAL' \
--output text)
if [ "$FINDINGS" != "None" ] && [ "$FINDINGS" -gt 0 ]; then
echo "CRITICAL vulnerabilities found: $FINDINGS — blocking deploy"
exit 1
fi
Scan findings are available via describe-image-scan-findings and also appear in the ECR console under the repository's image list. BasicScanning results are available within minutes of push; EnhancedScanning results typically appear within 5-10 minutes and update continuously as new CVEs are published — a previously-clean image may later be flagged without a new push.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| "no basic auth credentials" during ECS image pull | ecr:GetAuthorizationToken denied — either not in policy, or granted on repo ARN instead of * | Add ecr:GetAuthorizationToken with Resource: "*" to execution role |
| "denied: Your authorization token has expired" | ECR auth token used after 12-hour expiry | Re-run aws ecr get-login-password | docker login ... before each build/deploy cycle |
ImageAlreadyExistsException on push | Tag immutability is on and tag already exists | Use git SHA or timestamp as image tag — never overwrite existing tags in immutable repos |
| Pull succeeds in us-east-1, fails in eu-west-1 | ECR is a regional service; repo exists only in one region | Enable ECR replication rules to sync the repository to each deployment region |
| Cross-account pull fails: "not authorized to perform ecr:GetAuthorizationToken" | GetAuthorizationToken is account-level — resource policy on the repo doesn't cover it | Add ecr:GetAuthorizationToken on Resource: "*" to the execution role's IAM policy in the pulling account (not the resource policy) |
| ECR storage costs growing unboundedly | No lifecycle policy — untagged images accumulate after every :latest push | Add lifecycle rule: delete untagged images after 1 day |
| Scan shows CRITICAL findings but deploy wasn't blocked | Scanning runs asynchronously; CI checked findings before scan completed (status = IN_PROGRESS) | Poll imageScanStatus.status until COMPLETE before checking findings |