Guide · AWS ECR · Container Operations
ECR Lifecycle Policies for MCP Servers — JSON Format, Rule Evaluation, Preview API
An ECR repository without a lifecycle policy accumulates images indefinitely — every push, even to an existing tag, leaves the previous manifest as an untagged image billed at $0.10/GB-month. Three subtleties catch teams: lifecycle rules evaluate in priority order and the first matching rule wins (lower numeric priority = higher priority, so priority 1 beats priority 2); tagPatternList supports * wildcards but tagPrefixList does not — teams that try "tagPrefixList": ["v*"] to match all version tags will not get wildcards, they will get the literal prefix v*; and the lifecycle policy preview API tests your rules against the current repository before applying — use it before touching production to avoid accidentally expiring images that are still referenced by a running ECS task.
TL;DR
Lifecycle policy JSON lives in {"rules": [...]}. Each rule has a rulePriority, description, selection (tagStatus + countType + countNumber/countUnit), and action.type: "expire". Use tagPatternList with * for wildcard tag matching; use tagPrefixList only for exact prefix matching without wildcards. Test with start-lifecycle-policy-preview + get-lifecycle-policy-preview before applying to production. Keep at least one rule that prevents :latest expiry if your ECS task definitions reference it.
Lifecycle policy JSON structure
ECR lifecycle policies are a single JSON document with a rules array. There is no CDK-level abstraction for the full JSON — CDK's lifecycleRules prop translates to this underlying format, but understanding the raw JSON is required when debugging why a rule matched (or didn't).
// Full lifecycle policy JSON structure
{
"rules": [
{
"rulePriority": 1, // lower = higher priority; 1..100
"description": "Keep last 20 versioned release images",
"selection": {
"tagStatus": "tagged", // "tagged" | "untagged" | "any"
"tagPatternList": ["v*"], // wildcard; use tagPrefixList for exact prefix
"countType": "imageCountMoreThan", // "imageCountMoreThan" | "sinceImagePushed"
"countNumber": 20 // images to keep (with imageCountMoreThan)
},
"action": { "type": "expire" } // only "expire" exists; there is no "archive"
},
{
"rulePriority": 2,
"description": "Delete untagged images older than 1 day",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days", // required when countType = sinceImagePushed
"countNumber": 1
},
"action": { "type": "expire" }
},
{
"rulePriority": 3,
"description": "Hard cap: expire any image beyond 50 total",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 50
},
"action": { "type": "expire" }
}
]
}
Key constraints: a policy may have up to 50 rules. Priority numbers must be unique across all rules in a policy. The tagStatus: "any" catch-all rule, if present, must have the highest numeric priority value (lowest priority) — because it matches everything, placing it at a lower number would prevent more specific rules from ever matching.
tagPatternList vs tagPrefixList — the wildcard trap
ECR supports two ways to specify which tagged images a rule applies to. The difference is whether wildcards are supported:
| Field | Wildcard support | Example | What it matches |
|---|---|---|---|
tagPatternList | Yes — * only | ["v*"] | All tags starting with v: v1.0.0, v2.3.1-rc1 |
tagPatternList | Yes | ["*-prod"] | All tags ending with -prod: v1.0.0-prod, release-prod |
tagPatternList | Yes | ["*"] | All tagged images (equivalent to tagStatus: "any" for tagged) |
tagPrefixList | No — literal prefix | ["v"] | All tags whose text starts with the literal character v |
tagPrefixList | No | ["v*"] | Matches the literal tag v* only — not a wildcard |
Use tagPatternList for all new rules. tagPrefixList predates tagPatternList and exists for backwards compatibility. The prefix matching of tagPrefixList: ["v"] is identical to tagPatternList: ["v*"] but the former is less expressive. A rule cannot have both tagPatternList and tagPrefixList — use one or the other.
// WRONG: This does NOT match "v1.0.0" — it matches the literal tag "v*"
{
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["v*"], // literal "v*" prefix, NOT a wildcard
"countType": "imageCountMoreThan",
"countNumber": 10
}
}
// CORRECT: Use tagPatternList for wildcard matching
{
"selection": {
"tagStatus": "tagged",
"tagPatternList": ["v*"], // matches v1.0.0, v2.3.1-rc1, etc.
"countType": "imageCountMoreThan",
"countNumber": 10
}
}
countType: imageCountMoreThan vs sinceImagePushed
The two countType values express different retention strategies:
| countType | Required fields | Semantics | Best for |
|---|---|---|---|
imageCountMoreThan | countNumber | Expire images when the matching set has more than N images; keeps the N most recently pushed | Release tags with bounded count (v* → keep last 20) |
sinceImagePushed | countNumber + countUnit | Expire images pushed more than N days ago (only "days" is valid for countUnit) | Untagged images (delete after 1 day), dev/PR branch images (delete after 7 days) |
Important ordering caveat for imageCountMoreThan: ECR sorts images by push date descending and expires from the oldest end. The N images kept are the most recently pushed, not the most recently pulled. If you rely on :latest being always present but your pipeline pushes infrequently, a high-volume CI system pushing SHA-tagged images to the same repository could push :latest out of the "keep 20" window if you use a single catch-all rule. Use separate rules per tag pattern to isolate retention.
// Production lifecycle policy for a MCP server repository:
// Rule 1 protects :latest and named environment tags unconditionally
// Rule 2 keeps last 30 versioned releases
// Rule 3 keeps last 10 git-SHA CI images (PR builds)
// Rule 4 deletes untagged images after 1 day
// Rule 5 hard cap
{
"rules": [
{
"rulePriority": 1,
"description": "Never expire protected tags",
"selection": {
"tagStatus": "tagged",
"tagPatternList": ["latest", "stable", "prod", "staging"],
"countType": "imageCountMoreThan",
"countNumber": 99999
},
"action": { "type": "expire" }
},
{
"rulePriority": 2,
"description": "Keep last 30 versioned releases",
"selection": {
"tagStatus": "tagged",
"tagPatternList": ["v*"],
"countType": "imageCountMoreThan",
"countNumber": 30
},
"action": { "type": "expire" }
},
{
"rulePriority": 3,
"description": "Keep last 10 CI SHA images",
"selection": {
"tagStatus": "tagged",
"tagPatternList": ["sha-*", "ci-*", "pr-*"],
"countType": "imageCountMoreThan",
"countNumber": 10
},
"action": { "type": "expire" }
},
{
"rulePriority": 4,
"description": "Delete untagged images after 1 day",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 1
},
"action": { "type": "expire" }
},
{
"rulePriority": 5,
"description": "Hard cap at 100 total images",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 100
},
"action": { "type": "expire" }
}
]
}
Rule evaluation order and the first-match trap
ECR evaluates rules in ascending priority order (priority 1 first). When a rule matches an image, that rule determines the action for that image and no subsequent rules are evaluated against it. This means rule ordering directly determines which images survive.
The hard-cap trap: if you place a tagStatus: "any" hard-cap rule at priority 1 and your version-tag keep rule at priority 2, the hard-cap matches all images first and potentially expires your newest versioned releases before the keep rule has a chance to protect them.
// WRONG ordering: hard cap at priority 1 may expire images the keep rule would protect
{
"rules": [
{ "rulePriority": 1, "selection": { "tagStatus": "any", "countType": "imageCountMoreThan", "countNumber": 50 }, ... },
{ "rulePriority": 2, "selection": { "tagStatus": "tagged", "tagPatternList": ["v*"], "countType": "imageCountMoreThan", "countNumber": 30 }, ... }
]
}
// CORRECT: specific rules first, catch-all last
{
"rules": [
{ "rulePriority": 1, "selection": { "tagStatus": "tagged", "tagPatternList": ["v*"], ... }, ... },
{ "rulePriority": 2, "selection": { "tagStatus": "untagged", ... }, ... },
{ "rulePriority": 3, "selection": { "tagStatus": "any", "countType": "imageCountMoreThan", "countNumber": 100 }, ... }
]
}
The lifecycle policy engine processes each image independently against the rule list. An image matched by rule 1 is not re-evaluated by rule 3, even if it would also match rule 3.
Lifecycle policy preview — dry run before applying
ECR provides a preview API that evaluates a lifecycle policy against the current repository contents and returns which images would be expired, without actually expiring them. Use this before applying any lifecycle policy change to a production repository.
## Step 1: Start a preview (evaluates a policy JSON against the current repo)
aws ecr start-lifecycle-policy-preview \
--repository-name mcp-server \
--lifecycle-policy-text file://lifecycle-policy.json
## Output includes: {"status": "IN_PROGRESS"}
## Wait for the preview to complete (usually < 30 seconds)
## Step 2: Poll until COMPLETE
aws ecr get-lifecycle-policy-preview \
--repository-name mcp-server \
--query 'status'
# "COMPLETE"
## Step 3: Inspect which images would be expired
aws ecr get-lifecycle-policy-preview \
--repository-name mcp-server \
--query 'previewResults[?action.type==`EXPIRE`].[imageDigest,imageTags,appliedRulePriority]' \
--output table
## Output columns: imageDigest | imageTags (comma-separated) | rule that matched
## Step 4: If the preview looks correct, apply the policy
aws ecr put-lifecycle-policy \
--repository-name mcp-server \
--lifecycle-policy-text file://lifecycle-policy.json
The preview result includes appliedRulePriority for each image that would be expired — this tells you which rule matched, which is essential for debugging unexpected expirations. Images not listed in the preview are not matched by any expire rule and will be kept.
Preview limitations: the preview runs against the repository state at the moment start-lifecycle-policy-preview is called. If images are pushed during the preview, they are not included. New images pushed after applying a policy are evaluated by the policy at the next lifecycle evaluation cycle (runs daily at a time chosen by AWS).
Applying and inspecting policies via CLI and CDK
## Apply a lifecycle policy from a JSON file
aws ecr put-lifecycle-policy \
--repository-name mcp-server \
--lifecycle-policy-text file://lifecycle-policy.json
## Retrieve the current lifecycle policy (for review or backup)
aws ecr get-lifecycle-policy \
--repository-name mcp-server \
--query 'lifecyclePolicyText' \
--output text | jq .
## Delete the lifecycle policy (dangerous: repository will accumulate indefinitely)
aws ecr delete-lifecycle-policy \
--repository-name mcp-server
// CDK: apply raw lifecycle policy JSON directly (bypasses CDK's lifecycleRules abstraction)
import * as ecr from "aws-cdk-lib/aws-ecr";
import * as cr from "aws-cdk-lib/custom-resources";
const repo = new ecr.Repository(this, "McpServerRepo", {
repositoryName: "mcp-server",
imageTagMutability: ecr.TagMutability.IMMUTABLE,
imageScanOnPush: true,
});
// CDK's lifecycleRules prop does not support tagPatternList (only tagPrefixList).
// Use a custom resource to apply the raw policy JSON instead.
new cr.AwsCustomResource(this, "LifecyclePolicy", {
onCreate: {
service: "ECR",
action: "putLifecyclePolicy",
parameters: {
repositoryName: repo.repositoryName,
lifecyclePolicyText: JSON.stringify({
rules: [
{
rulePriority: 1,
description: "Keep last 30 versioned releases",
selection: {
tagStatus: "tagged",
tagPatternList: ["v*"],
countType: "imageCountMoreThan",
countNumber: 30,
},
action: { type: "expire" },
},
{
rulePriority: 2,
description: "Delete untagged images after 1 day",
selection: {
tagStatus: "untagged",
countType: "sinceImagePushed",
countUnit: "days",
countNumber: 1,
},
action: { type: "expire" },
},
],
}),
},
physicalResourceId: cr.PhysicalResourceId.of("McpServerLifecyclePolicy"),
},
policy: cr.AwsCustomResourcePolicy.fromSdkCalls({ resources: [repo.repositoryArn] }),
});
AliveMCP: lifecycle policy for probe worker images
AliveMCP's probe worker is packaged as a Docker image and pushed to ECR on every CI build. The probe worker runs continuously on ECS Fargate across multiple regions. Without a lifecycle policy, the alivemcp/probe-worker repository would accumulate thousands of images — each CI build for each active branch pushes a new SHA-tagged image.
The production lifecycle policy keeps three classes of images: the two most recent stable and prod tags (ECS task definitions reference these), the last 20 versioned releases (v*), and deletes all other images older than 7 days. This keeps ECR storage below 2 GB ($0.20/month) instead of the 50+ GB that would accumulate unmanaged ($5+/month). The preview API is run as part of the CDK deployment pipeline — if the preview shows the current prod tag would be expired, the pipeline fails and alerts on-call.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| ECS task fails to start after lifecycle policy runs — image pull fails | Lifecycle policy expired an image that an active ECS task definition references | Add a rule protecting the tags referenced by task definitions (prod, latest, or specific SHA); verify with preview before applying |
tagPrefixList: ["v*"] does not match versioned tags | tagPrefixList does not support wildcards; v* is treated as the literal prefix v* | Replace with tagPatternList: ["v*"] |
| Hard-cap rule at priority 1 expires newest images before keep rules run | Rules evaluate in ascending priority order; catch-all rule at priority 1 matches everything first | Move the hard-cap rule to the highest numeric priority (last rule) |
| Untagged images still accumulate despite a sinceImagePushed 1-day rule | Lifecycle evaluation runs once per day at a time chosen by AWS; images pushed today will not expire until tomorrow's evaluation cycle | This is expected behavior — lifecycle evaluation is not real-time |
| Preview shows no images would be expired, but storage is still high | Policy was applied before existing images were pushed; lifecycle evaluation only runs on the next daily cycle after policy creation | Wait 24 hours or check CloudTrail for StartLifecyclePolicyPreview to confirm the policy was applied |
CDK lifecycleRules prop generates prefix rules but you need pattern rules | CDK's lifecycleRules uses tagPrefixList internally and does not support tagPatternList | Use a CDK custom resource to apply the raw lifecycle policy JSON directly |