Right, so you’ve built your container image, pushed it to ECR, and deployed it to production. Life is good. Then, a week later, you run a simple docker push after a bug fix and suddenly your staging environment, which was humming along nicely, starts behaving like it’s possessed. Why? Because you just overwrote the :staging tag with a new image digest. The tag moved, but your running containers didn’t get the memo. They’re still running the old digest, blissfully unaware that their supposed identity has been stolen. This is the chaos that image tag immutability is designed to prevent.

Think of a tag as a post-it note. By default, you can scribble a name on it, slap it on a container image, and then later peel it off and stick it on a completely different image. This is mutability. It’s convenient for continuous development where you want :latest to always mean, well, the latest build. But for anything resembling a stable environment—staging, production, v1.2, whatever—it’s a nightmare. Immutability is the superglue that welds that post-it note to a single, unique image digest forever. Once it’s on, it’s never coming off.

Enabling the Magic (and the Inevitable Regret)

You enable this at the repository level, and this is the first gotcha: it’s all or nothing. You can’t make just your :production tag immutable while leaving :latest flapping in the wind. The entire repository gets this behavior. You set it up either at creation time or you can update it later. Doing it via the AWS Console is straightforward (it’s a checkbox in the repository settings), but we’re engineers, so let’s use code. Here’s how you create a new repository with it turned on using Terraform:

resource "aws_ecr_repository" "my_immutable_repo" {
  name                 = "my-super-stable-app"
  image_tag_mutability = "IMMUTABLE"

  # You'll almost always want this on too, because what's the point of immutable tags if you can just delete the image?
  image_scanning_configuration {
    scan_on_push = true
  }
}

And if you’ve already got a repository that’s been the wild west of tag rewriting, you can put it on the straight and narrow with the AWS CLI. But a word of caution: this change is irreversible. You can go from mutable to immutable, but AWS won’t let you go back. Think of it as a vow of celibacy for your container tags.

aws ecr put-image-tag-mutability \
    --repository-name my-previously-mutable-repo \
    --image-tag-mutability IMMUTABLE

The Beautiful Error of Failure

So what happens now when you try to break the rules? Let’s say you push my-app:1.0.0 to your new immutable repository. Later, you build a new image, and try to push it again with the same tag.

docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-super-stable-app:1.0.0

Instead of a successful push, you’ll be greeted by a beautiful, glorious error from the ECR gods:

The push refers to repository [123456789012.dkr.ecr.us-east-1.amazonaws.com/my-super-stable-app]
9e84d6f82c46: Layer already exists
fd4d7b6fa2db: Layer already exists
4f4fb700ef54: Layer already exists
unknown: The image with tag '1.0.0' already exists in the repository and image tag mutability is set to IMMUTABLE.

This isn’t a failure; it’s a feature working as intended. It just saved you from a potential deployment disaster. The workflow now forces you to use a new tag for a new build. This is a good thing! It means your 1.0.0 tag is permanently and uniquely tied to that specific image digest. You can deploy it with absolute confidence that it will never change out from under you.

Best Practices and the SemVer Lifeline

“Hold on,” you say, “if I can’t push to :1.0.0, how do I deploy a patch?” This is the right question. The answer is semantic versioning and your CI/CD pipeline. Your build process needs to become the source of truth for versioning.

Your pipeline should automatically assign a new, unique tag for every build. For a v1.0.0 release, you might push the exact same image with multiple tags: :1.0.0, :1.0, and :1. The first two are immutable. The :1 tag, however, is a bit of a trick. You can’t overwrite it, but you can create it again pointing to a new digest. So when you build v1.1.0, you push with tags :1.1.0, :1.1, and :1. You’re not overwriting; you’re adding new immutable tags and creating a new “floating” major version tag. This is why you often see ECR repositories with thousands of images; it’s the trade-off for stability.

The real pro move is to use the git commit SHA as your immutable tag. It’s guaranteed to be unique for every build and directly ties the image back to its source code.

# Inside your CI/CD pipeline
IMAGE_TAG=$(git rev-parse --short HEAD)
docker build -t my-app:${IMAGE_TAG} .
docker tag my-app:${IMAGE_TAG} 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:${IMAGE_TAG}
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:${IMAGE_TAG}

Then, you update your Elastic Beanstalk task definition or your ECS service or your Kubernetes deployment to use that exact, immutable SHA tag. This is the way. It makes your deployments completely reproducible and auditable. You’re not deploying “whatever :latest is now”; you’re deploying a specific artifact. It removes a whole class of “it worked on my machine” problems, because your machine and the production machine are now unequivocally running the exact same binary. It’s one of the simplest and most powerful steps you can take towards mature, reliable deployments.