Cloud 05 — an ECR repository, a lifecycle policy, and the x86 image pushed from an ARM Mac

In Java you mvn deploy to Nexus or Artifactory and a release is forever: 1.4.2 is 1.4.2, and the repository refuses a second upload of it. A container registry does not work that way by default. A tag is a movable pointer — docker push myservice:latest quietly re-points latest at new bytes — and nothing ever deletes the old ones, so every dev push stays on the bill. This page makes an Amazon ECR repository that behaves like the Nexus you know: immutable tags, a scan on every push, and a lifecycle policy that expires old dev builds. Then it pushes the image cloud-01 cross-built on your ARM Mac, so cloud-06's Fargate service has something to pull.

~45 min~$0.02/mo (approx.) 2 resourcesthe repo stays cloud-05

What this creates

Two Terraform resources in ap-south-1: the repository (aws_ecr_repository, named lbv-api, tags IMMUTABLE, scan on push) and its lifecycle policy (aws_ecr_lifecycle_policy, two rules: keep the last 5 untagged images, keep the last 5 dev-* builds). ECR bills storage only — AWS's FAQ: "you pay only for the amount of data you store … and data transferred to the internet"; pulls by Fargate in the same region are $0.00 per GB (Amazon ECR pricing page, read 2026-09-10). Storage is $0.10 per GB-month, on the size ECR reports for each image — the compressed layers, which is why the console shows less than docker images on your Mac. cloud-01's image is under 250 MB on disk; call it ~180 MB in ECR (step 6 reads your real number). The meter adds each image's full reported size, which is the upper bound: ECR stores a byte-identical layer once, so builds that share a base layer cost somewhat less than the meter says — but every build with new dependencies or a new base adds nearly its whole size. Accounts in their first year may have 500 MB/month free; the meter ignores it. Prices are approx.: verify on the pricing page today.

Preconditions

Do it

1. Versions and backend — 05-ecr/versions.tf. Same bucket as cloud-03 and cloud-04, a new key of its own. Change only the bucket name:

# 05-ecr/versions.tf
terraform {
  required_version = "~> 1.11"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  backend "s3" {
    bucket       = "lbv-tfstate-yourname-x7q2"
    key          = "lbv-track/05-ecr/terraform.tfstate"
    region       = "ap-south-1"
    use_lockfile = true
    encrypt      = true
  }
}

provider "aws" {
  region = "ap-south-1"

  default_tags {
    tags = {
      Project = "lbv-track"
      Item    = "cloud-05"
    }
  }
}

2. The repository and its lifecycle policy — 05-ecr/main.tf. Read the two rules before you paste them. Rule 1 is the classic "keep the last 5 untagged": an image loses its tag when a mutable tag is re-pointed or a tag is deleted. With IMMUTABLE tags a push never un-tags anything, so rule 1 is only the safety net — the rule that actually bounds your storage is rule 2, because every dev build gets a tag of its own (dev-<git sha>). v1 matches neither rule, so no rule ever expires it:

# 05-ecr/main.tf
resource "aws_ecr_repository" "api" {
  name                 = "lbv-api"
  image_tag_mutability = "IMMUTABLE" # a pushed tag can never be re-pointed
  force_delete         = false       # destroy refuses while images exist -- on purpose

  image_scanning_configuration {
    scan_on_push = true # basic scanning, on every push
  }

  encryption_configuration {
    encryption_type = "AES256"
  }
}

resource "aws_ecr_lifecycle_policy" "api" {
  repository = aws_ecr_repository.api.name

  policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "keep the last 5 untagged images"
        selection = {
          tagStatus   = "untagged"
          countType   = "imageCountMoreThan"
          countNumber = 5
        }
        action = { type = "expire" }
      },
      {
        rulePriority = 2
        description  = "keep the last 5 dev builds"
        selection = {
          tagStatus     = "tagged"
          tagPrefixList = ["dev-"]
          countType     = "imageCountMoreThan"
          countNumber   = 5
        }
        action = { type = "expire" }
      },
    ]
  })
}

3. The one output later items read — 05-ecr/outputs.tf:

# 05-ecr/outputs.tf
output "repository_url" {
  value = aws_ecr_repository.api.repository_url # <account>.dkr.ecr.ap-south-1.amazonaws.com/lbv-api
}

4. Init, check, plan, apply.

cd ~/lbv-cloud/05-ecr
export AWS_PROFILE=lbv-admin
terraform init
terraform fmt -check
terraform validate
# Success! The configuration is valid.
terraform plan -out=tfplan
# Plan: 2 to add, 0 to change, 0 to destroy.
#   aws_ecr_repository.api + aws_ecr_lifecycle_policy.api
terraform apply tfplan
# Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
REPO=$(terraform output -raw repository_url); echo "$REPO"

5. Log Docker in to the registry. The password is a 12-hour token; it goes through a pipe, never into your shell history:

ACCT=$(aws sts get-caller-identity --query Account --output text)
aws ecr get-login-password --region ap-south-1 \
  | docker login --username AWS --password-stdin "$ACCT.dkr.ecr.ap-south-1.amazonaws.com"
# Login Succeeded

6. Cross-build for x86 and push, in one command — from cloud-01's folder, the one with the Dockerfile. --push sends the linux/amd64 image straight to ECR; it never has to exist in your Mac's local image store. --provenance=false is the one flag cloud-01 did not need: without it, recent buildx versions attach a provenance attestation, and the tag points at an image index holding your image plus a second, attestation manifest — more than one manifest behind one tag, which is not what the Verify step or the lifecycle rules above are counting:

cd ~/path/to/cloud-01          # the folder with the Dockerfile, requirements.txt and app/
docker buildx build --platform linux/amd64 --provenance=false --push -t "$REPO:v1" .
# ... => pushing layers ... => pushing manifest for <account>.dkr.ecr.ap-south-1.amazonaws.com/lbv-api:v1

# every later dev build gets its own tag -- rule 2 keeps the newest five:
# docker buildx build --platform linux/amd64 --provenance=false --push -t "$REPO:dev-$(git rev-parse --short HEAD)" .

Verify

Teardown

None now — keep the repository. cloud-06/07 push py-10's service into it as lbv-api:v2 (another tag that matches no lifecycle rule) and run it on Fargate. One ~180 MB image costs ~$0.02/mo (0.18 GB × $0.10/GB-month, approx., verify on the pricing page today), and the lifecycle policy keeps dev builds from growing it.

When the whole track is over — after cloud-07 has destroyed the service that pulls from it:

cd ~/lbv-cloud/05-ecr
IDS=$(aws ecr list-images --repository-name lbv-api --query 'imageIds[*]' --output json)
aws ecr batch-delete-image --repository-name lbv-api --image-ids "$IDS"
# force_delete = false: destroy refuses while any image is left, so delete them first
terraform destroy
# Destroy complete! Resources: 2 destroyed.
aws ecr describe-repositories --repository-names lbv-api
# An error occurred (RepositoryNotFoundException) ... -- the repository is gone

What remains and what it costs, approx., verify on the pricing page today: after this page, the repository with v1 in it — ~$0.02/mo. After the end-of-track teardown, nothing.

There is no teardown box on this page, because the repository stays. Press the button once the five Verify lines above are true.

This is self-attestation — the site cannot see your AWS account, so pressing the button is you telling The Path the repository exists, v1 is in it as linux/amd64, and a second push of v1 was refused.

Takeaways: a registry bills for what it keeps, and by default it keeps everything — the bill is cents per image but it only ever grows, one push at a time, until a lifecycle policy puts a ceiling on it. Rules match on tag status and tag prefix, so the policy has to fit how you tag: with immutable tags every build carries its own tag, and the rule that bounds storage is the one on dev-*, not the one on untagged images. Immutable tags are the other half: a tag that can be re-pointed means the same name can run different code tomorrow, and an image that lost its tag is exactly what an untagged-expiry rule deletes.