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.
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.
terraform apply before that e-mail.Dockerfile,
requirements.txt and app/ is on disk, and
docker buildx build --platform linux/amd64 -t myservice:amd64 --load . works there.
Docker Desktop is running.$B there),
terraform version prints 1.11 or later, and aws sts get-caller-identity
with AWS_PROFILE=lbv-admin names an assumed role, not :root. cloud-04's
VPC is not needed: ECR is a regional service that lives outside any VPC.~/lbv-cloud/05-ecr/, next to 04-vpc/.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)" .
aws ecr describe-images --repository-name lbv-api --query 'imageDetails[].[imageTags[0],imageSizeInBytes,imageManifestMediaType]' --output table
prints exactly one row: v1, a size in the region of 180,000,000 bytes (yours
differs), and a single-image manifest type — …docker.distribution.manifest.v2+json
or …oci.image.manifest.v1+json, not an …index… or
…manifest.list… type.linux/amd64. ECR's image record does not carry the platform, so ask the
image itself: docker pull --platform linux/amd64 "$REPO:v1" && docker image inspect "$REPO:v1" --format '{{.Os}}/{{.Architecture}}'
prints linux/amd64 — an x86 image, built and pushed from an ARM laptop. (One
~180 MB download to your Mac; internet transfer out of ECR is a couple of cents at most at
this size.)aws ecr describe-image-scan-findings --repository-name lbv-api --image-id imageTag=v1 --query 'imageScanStatus.status'
prints "COMPLETE" (give it a minute after the push). Basic scanning allows one scan
per image per 24 hours, and the scan on push is that one. AWS's API reference marks the
repository-level scan_on_push setting as being deprecated in favour of a
registry-level scanning configuration; if the status is not COMPLETE,
aws ecr get-registry-scanning-configuration shows what governs your account, and
aws ecr start-image-scan --repository-name lbv-api --image-id imageTag=v1 runs the
scan by hand.tag invalid error saying v1 already exists and the repository is
immutable — a tag is a release, exactly like Nexus.aws ecr get-lifecycle-policy --repository-name lbv-api --query lifecyclePolicyText --output text
prints the two rules. ECR applies them on its own schedule: AWS says expect images to expire
within 24 hours of meeting a rule, so nothing disappears the instant you push a sixth dev
build.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.
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.