Quick take — A reusable Terraform module for AWS ECR repositories with image scanning, KMS encryption, immutable tags, lifecycle expiry, and least-privilege repository policies for production CI/CD pipelines. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "ecr" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ecr?ref=v1.0.0"
name = "..." # ECR repository name; lowercase, may include `/` namespa…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Amazon Elastic Container Registry (ECR) is AWS’s managed, OCI-compatible registry for storing and distributing container images and Helm charts. A private ECR repository is the per-image-name unit you push to — for example accounts-service or web-frontend — and it carries its own encryption settings, tag mutability rules, image scanning configuration, lifecycle policy, and resource-based access policy.
Left to the console defaults, a repository ships with mutable tags (so :latest can silently change underneath a running deployment), AES-256 encryption with an AWS-owned key you can’t audit, and no lifecycle policy, which means every untagged layer from every CI build accumulates forever and quietly grows your storage bill. This module wraps aws_ecr_repository together with the three sub-resources that almost every production repo needs — aws_ecr_lifecycle_policy, aws_ecr_repository_policy, and KMS encryption — so each registry is created scan-on-push, tag-immutable, KMS-encrypted, garbage-collected, and locked to least-privilege pull/push principals from the first apply, with zero click-ops drift.
When to use it
- You run CI/CD that builds and pushes container images (CodeBuild, GitHub Actions via OIDC, GitLab, Azure Pipelines) and want every repo created with identical guardrails.
- You deploy to ECS Fargate, EKS, App Runner, or Lambda container images and need a stable, scanned, immutable image source.
- Your compliance baseline (CIS AWS, SOC 2, PCI) requires customer-managed KMS encryption, scan-on-push, and audited repository access policies.
- You want predictable storage cost control via lifecycle expiry of untagged and old tagged images instead of unbounded growth.
- You manage many microservice repositories and need a single, versioned definition rather than per-team console clicks.
Reach for the AWS-managed public gallery (aws_ecrpublic_repository) instead only when you are distributing images to the world; this module targets private registries.
Module structure
terraform-module-aws-ecr/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# KMS encryption requires the encryption_type to be "KMS". When the caller
# supplies a key ARN we honor it; otherwise AWS provisions an AWS-managed
# KMS key for the repository. AES256 ignores the kms_key argument entirely.
encryption_type = var.kms_key_arn != null ? "KMS" : var.encryption_type
}
resource "aws_ecr_repository" "this" {
name = var.name
image_tag_mutability = var.image_tag_mutability
force_delete = var.force_delete
image_scanning_configuration {
scan_on_push = var.scan_on_push
}
encryption_configuration {
encryption_type = local.encryption_type
kms_key = local.encryption_type == "KMS" ? var.kms_key_arn : null
}
tags = var.tags
}
# Registry-wide enhanced scanning (Amazon Inspector) is opt-in per account.
# Enabling it here applies CONTINUOUS or scan-on-push enhanced scanning that
# filters down to this repository name.
resource "aws_ecr_registry_scanning_configuration" "this" {
count = var.enhanced_scanning ? 1 : 0
scan_type = "ENHANCED"
rule {
scan_frequency = var.enhanced_scan_frequency
repository_filter {
filter = var.name
filter_type = "WILDCARD"
}
}
}
# Expire untagged images quickly and cap retained tagged builds so storage
# does not grow without bound across CI runs.
resource "aws_ecr_lifecycle_policy" "this" {
count = var.enable_lifecycle_policy ? 1 : 0
repository = aws_ecr_repository.this.name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Expire untagged images older than ${var.untagged_image_expiry_days} days"
selection = {
tagStatus = "untagged"
countType = "sinceImagePushed"
countUnit = "days"
countNumber = var.untagged_image_expiry_days
}
action = { type = "expire" }
},
{
rulePriority = 2
description = "Keep only the most recent ${var.max_tagged_image_count} tagged images"
selection = {
tagStatus = "tagged"
tagPrefixList = var.lifecycle_tag_prefix_list
countType = "imageCountMoreThan"
countNumber = var.max_tagged_image_count
}
action = { type = "expire" }
}
]
})
}
# Least-privilege resource policy: explicit pull-only and push-allowed principals.
data "aws_iam_policy_document" "repo" {
count = length(var.pull_principal_arns) > 0 || length(var.push_principal_arns) > 0 ? 1 : 0
dynamic "statement" {
for_each = length(var.pull_principal_arns) > 0 ? [1] : []
content {
sid = "AllowPull"
effect = "Allow"
principals {
type = "AWS"
identifiers = var.pull_principal_arns
}
actions = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability",
]
}
}
dynamic "statement" {
for_each = length(var.push_principal_arns) > 0 ? [1] : []
content {
sid = "AllowPush"
effect = "Allow"
principals {
type = "AWS"
identifiers = var.push_principal_arns
}
actions = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
]
}
}
}
resource "aws_ecr_repository_policy" "this" {
count = length(var.pull_principal_arns) > 0 || length(var.push_principal_arns) > 0 ? 1 : 0
repository = aws_ecr_repository.this.name
policy = data.aws_iam_policy_document.repo[0].json
}
# variables.tf
variable "name" {
description = "Name of the ECR repository (e.g. accounts-service). May include namespace path segments."
type = string
validation {
condition = can(regex("^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$", var.name))
error_message = "Repository name must be lowercase alphanumeric, may use . _ - separators and / for namespacing."
}
}
variable "image_tag_mutability" {
description = "Tag mutability setting. IMMUTABLE prevents overwriting tags such as :latest (recommended for prod)."
type = string
default = "IMMUTABLE"
validation {
condition = contains(["MUTABLE", "IMMUTABLE"], var.image_tag_mutability)
error_message = "image_tag_mutability must be either MUTABLE or IMMUTABLE."
}
}
variable "scan_on_push" {
description = "Enable basic vulnerability scanning automatically when an image is pushed."
type = bool
default = true
}
variable "enhanced_scanning" {
description = "Enable Amazon Inspector enhanced scanning for this repository via a registry scanning rule."
type = bool
default = false
}
variable "enhanced_scan_frequency" {
description = "Enhanced scan frequency when enhanced_scanning is true: SCAN_ON_PUSH or CONTINUOUS_SCAN."
type = string
default = "CONTINUOUS_SCAN"
validation {
condition = contains(["SCAN_ON_PUSH", "CONTINUOUS_SCAN"], var.enhanced_scan_frequency)
error_message = "enhanced_scan_frequency must be SCAN_ON_PUSH or CONTINUOUS_SCAN."
}
}
variable "encryption_type" {
description = "Encryption type when no KMS key ARN is provided: AES256 or KMS (AWS-managed key)."
type = string
default = "AES256"
validation {
condition = contains(["AES256", "KMS"], var.encryption_type)
error_message = "encryption_type must be AES256 or KMS."
}
}
variable "kms_key_arn" {
description = "ARN of a customer-managed KMS key for encryption at rest. When set, encryption_type is forced to KMS."
type = string
default = null
}
variable "force_delete" {
description = "Allow Terraform to delete the repository even if it still contains images. Use with caution."
type = bool
default = false
}
variable "enable_lifecycle_policy" {
description = "Attach a lifecycle policy that expires untagged and surplus tagged images."
type = bool
default = true
}
variable "untagged_image_expiry_days" {
description = "Expire untagged images older than this many days."
type = number
default = 14
validation {
condition = var.untagged_image_expiry_days >= 1
error_message = "untagged_image_expiry_days must be at least 1."
}
}
variable "max_tagged_image_count" {
description = "Maximum number of matching tagged images to retain before expiring the oldest."
type = number
default = 30
validation {
condition = var.max_tagged_image_count >= 1
error_message = "max_tagged_image_count must be at least 1."
}
}
variable "lifecycle_tag_prefix_list" {
description = "Tag prefixes the tagged-image retention rule applies to (e.g. [\"v\", \"release-\"])."
type = list(string)
default = ["v"]
}
variable "pull_principal_arns" {
description = "IAM principal ARNs (roles/accounts) granted pull-only access via the repository policy."
type = list(string)
default = []
}
variable "push_principal_arns" {
description = "IAM principal ARNs (e.g. CI build roles) granted push access via the repository policy."
type = list(string)
default = []
}
variable "tags" {
description = "Tags applied to the repository."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "The registry ID and repository name (the repository identifier)."
value = aws_ecr_repository.this.id
}
output "name" {
description = "The name of the ECR repository."
value = aws_ecr_repository.this.name
}
output "arn" {
description = "Full ARN of the repository."
value = aws_ecr_repository.this.arn
}
output "repository_url" {
description = "The URL of the repository (account.dkr.ecr.region.amazonaws.com/name) used in docker push/pull and image references."
value = aws_ecr_repository.this.repository_url
}
output "registry_id" {
description = "The AWS account ID of the registry that owns the repository."
value = aws_ecr_repository.this.registry_id
}
How to use it
module "ecr_repository" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ecr?ref=v1.0.0"
name = "accounts-service"
image_tag_mutability = "IMMUTABLE"
scan_on_push = true
enhanced_scanning = true
kms_key_arn = aws_kms_key.ecr.arn
enable_lifecycle_policy = true
untagged_image_expiry_days = 7
max_tagged_image_count = 20
lifecycle_tag_prefix_list = ["v", "sha-"]
# CI build role may push; the ECS task execution role may only pull.
push_principal_arns = [aws_iam_role.ci_build.arn]
pull_principal_arns = [aws_iam_role.ecs_task_execution.arn]
tags = {
Environment = "prod"
Team = "payments"
ManagedBy = "terraform"
}
}
# Downstream: feed the repository URL into an ECS task definition image reference.
resource "aws_ecs_task_definition" "accounts" {
family = "accounts-service"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "512"
memory = "1024"
execution_role_arn = aws_iam_role.ecs_task_execution.arn
container_definitions = jsonencode([
{
name = "accounts-service"
image = "${module.ecr_repository.repository_url}:v1.4.2"
essential = true
portMappings = [{ containerPort = 8080, protocol = "tcp" }]
}
])
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/ecr/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ecr?ref=v1.0.0"
}
inputs = {
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/ecr && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | ECR repository name; lowercase, may include / namespace segments. |
image_tag_mutability |
string |
"IMMUTABLE" |
No | MUTABLE or IMMUTABLE; immutable blocks tag overwrites. |
scan_on_push |
bool |
true |
No | Enable basic vulnerability scan on push. |
enhanced_scanning |
bool |
false |
No | Enable Amazon Inspector enhanced scanning via a registry rule. |
enhanced_scan_frequency |
string |
"CONTINUOUS_SCAN" |
No | SCAN_ON_PUSH or CONTINUOUS_SCAN for enhanced scanning. |
encryption_type |
string |
"AES256" |
No | AES256 or KMS when no kms_key_arn is supplied. |
kms_key_arn |
string |
null |
No | Customer-managed KMS key ARN; forces encryption_type to KMS. |
force_delete |
bool |
false |
No | Allow deleting a non-empty repository on destroy. |
enable_lifecycle_policy |
bool |
true |
No | Attach the untagged/tagged expiry lifecycle policy. |
untagged_image_expiry_days |
number |
14 |
No | Expire untagged images older than this many days. |
max_tagged_image_count |
number |
30 |
No | Max matching tagged images to keep before expiring oldest. |
lifecycle_tag_prefix_list |
list(string) |
["v"] |
No | Tag prefixes the tagged-retention rule matches. |
pull_principal_arns |
list(string) |
[] |
No | Principals granted pull-only access. |
push_principal_arns |
list(string) |
[] |
No | Principals granted push access (e.g. CI roles). |
tags |
map(string) |
{} |
No | Tags applied to the repository. |
Outputs
| Name | Description |
|---|---|
id |
Repository identifier (registry ID + repository name). |
name |
The ECR repository name. |
arn |
Full ARN of the repository. |
repository_url |
Repository URL used in docker push/pull and image references. |
registry_id |
AWS account ID of the owning registry. |
Enterprise scenario
A payments platform runs 40+ microservices on ECS Fargate, each with its own ECR repository declared through a for_each over this module. CI build roles (assumed via GitHub Actions OIDC) get scoped push_principal_arns, while each service’s ECS task execution role gets pull-only via pull_principal_arns, satisfying the auditor’s least-privilege requirement. Every repository is KMS-encrypted with the team’s CMK, tag-immutable so a deployed v1.4.2 can never be silently replaced, and Inspector enhanced scanning continuously re-evaluates stored images against new CVEs — turning a fleet-wide compliance control into a single versioned module bump.
Best practices
- Keep tags immutable in production.
IMMUTABLEmutability guarantees a SHA-pinned or version-pinned image is byte-for-byte reproducible and prevents an attacker (or a careless re-push) from swapping:latestunder a live service. - Encrypt with a customer-managed KMS key for regulated workloads. Passing
kms_key_arngives you key rotation, grant auditing in CloudTrail, and the ability to revoke access — none of which the default AWS-owned key offers; remember encryption type is fixed at creation, so set it up front. - Always attach a lifecycle policy. Untagged layers from every CI build are pure cost with no value; expiring them after 7–14 days and capping tagged retention keeps ECR storage spend flat instead of compounding monthly.
- Scope the repository policy to explicit principals. Use
pull_principal_arnsandpush_principal_arnsso only CI roles push and only runtime roles pull — never grantecr:*or rely on broad account-level IAM when a tight resource policy will do. - Turn on scanning and act on it.
scan_on_pushis cheap insurance; for sustained coverage enableenhanced_scanning(Inspector) so previously-pushed images are re-scanned as new vulnerabilities are disclosed, and wire findings into your alerting. - Name repositories by service, namespaced by domain. Consistent names like
payments/accounts-servicemake IAM conditions, lifecycle prefixes, and cross-account replication rules predictable, and avoid collisions as the fleet grows.