IaC AWS

Terraform Module: AWS ECR Repository — hardened, scan-on-push container registries by default

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

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 configlive/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 configlive/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

TerraformAWSECR RepositoryModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading