How to Build and Push a Docker Image to ECR with Terraform

Tony Tannous
6 min readJun 8, 2023

The build/push of docker images to ECR using Terraform can be accomplished using the null provider. Depending on the version of Terraform you have running, a more favourable (or “modern”) alternative would be to use providers/kreuzwerker.

With that said, the null provider can make for a good fallback option. This article provides a step-by-step demo showing how to use this provider to build/push an image to ECR.

Prerequisites

If you choose to run the code samples using your own AWS account, be mindful that this will incur a cost. ECR is not part of the free-tier, however, the image being pushed is only a couple of BYTES in size, which should keep costs low. Alternatively, you could explore using localstack.

The following sample values are used throughout the demo. These will need to be changed to reflect values matching your environment.

  1. AWS account id: 111111111111
  2. AWS CLI profile name: demo_terraform

Below is a sample profile

$HOME/.aws/config

[profile demo_terraform]
region = us-east-1
output = json
aws_access_key_id = XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
aws_secret_access_key = YYYYYYYYYYYYYYYYYYYYYYYYYYYYYY

3. Repo named demo needs to be created in your default ECR registry

For the demo, 111111111111.dkr.ecr.us-east-1.amazonaws.com/demo is used.

You can create the repo using the command:

$ aws --profile demo_terraform ecr create-repository --repository-name demo

Create Terraform and Docker folders/files

Create Demo Project Folder Structure

$ mkdir -p $HOME/demo/docker-src

For the purposes of the demo, a very small image based off scratch will be used.

Create Docker Image files

  • Dockerfile

$HOME/demo/docker-src/Dockerfile

FROM scratch
COPY docker-src/demofile /
CMD ["/demofile"]
  • Since ECR doesn’t seem to support images with empty layers, we’ll create a file to copy over to the image
  • Create $HOME/demo/docker-src/demofile with contents as below
demofile

Create Terraform main.tf

Create the file shown below $HOME/demo/main.tfand be sure to update the following to match your account:

aws_account      = "111111111111"
aws_region = "us-east-1"

$HOME/demo/main.tf

terraform {
required_version = ">= 0.12"
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}

provider "aws" {
region = local.aws_region
profile = local.aws_profile

}

locals {

//////////////////////////////////////////////////////////////////////////////////////////////
///////////// Substitute below values to match your AWS account, region & profile //////////////////////////////////////////////////////////////////////////////////////////////
aws_account = "111111111111" # AWS account
aws_region = "us-east-1" # AWS region
aws_profile = "demo_terraform" # AWS profile
/////////////////////////////////////////////////////////////////////////////////////////////
ecr_reg = "${local.aws_account}.dkr.ecr.${local.aws_region}.amazonaws.com" # ECR docker registry URI
ecr_repo = "demo" # ECR repo name
image_tag = "latest" # image tag

dkr_img_src_path = "${path.module}/docker-src"
dkr_img_src_sha256 = sha256(join("", [for f in fileset(".", "${local.dkr_img_src_path}/**") : file(f)]))

dkr_build_cmd = <<-EOT
docker build -t ${local.ecr_reg}/${local.ecr_repo}:${local.image_tag} \
-f ${local.dkr_img_src_path}/Dockerfile .

aws --profile ${local.aws_profile} ecr get-login-password --region ${local.aws_region} | \
docker login --username AWS --password-stdin ${local.ecr_reg}

docker push ${local.ecr_reg}/${local.ecr_repo}:${local.image_tag}
EOT

}

variable "force_image_rebuild" {
type = bool
default = false
}

# local-exec for build and push of docker image
resource "null_resource" "build_push_dkr_img" {
triggers = {
detect_docker_source_changes = var.force_image_rebuild == true ? timestamp() : local.dkr_img_src_sha256
}
provisioner "local-exec" {
command = local.dkr_build_cmd
}
}

output "trigged_by" {
value = null_resource.build_push_dkr_img.triggers
}

Final Directory Structure

The final project structure and contents should resemble the following:

$HOME/demo
|
├── docker-src
│ ├── Dockerfile
│ └── demofile
├── main.tf

Overview of main.tf Logic

The key local variables defined within main.tf are as follows:

...
locals {
...
...
dkr_img_src_path = "${path.module}/docker-src"
dkr_img_src_sha256 = sha256(join("", [for f in fileset(".", "${local.dkr_img_src_path}/**") : file(f)]))

dkr_build_cmd = <<-EOT
docker build -t ${local.ecr_reg}/${local.ecr_repo}:${local.image_tag} \
-f ${local.dkr_img_src_path}/Dockerfile .

aws --profile ${local.aws_profile} ecr get-login-password --region ${local.aws_region} | \
docker login --username AWS --password-stdin ${local.ecr_reg}

docker push ${local.ecr_reg}/${local.ecr_repo}:${local.image_tag}
EOT
  • dkr_img_src_path: This is the path to where all docker build artifacts are located, and resolves to $HOME/demo/docker-src/
  • dkr_img_src_sha256: This variable concatenates (joins) the contents of all files at the above path (recursively) and calculates their combined SHA256 hash.
    Why are we doing this? Remember that the null_resource operator will do nothing unless it is "triggered". The sha256 hash is our trigger. In other words, when this hash changes value, the image should be built/refreshed and pushed
  • dkr_build_cmd: shell commands (heredoc) which are sent to the null_resource provider to build and push the image to ECR

You will also notice an additional variable (more on why this variable has been included later on):

variable "force_image_rebuild" {
type = bool
default = false
}

Terraform Init/Plan/Apply

Now we can run some commands and make some observations.

Initialise Terraform

$ cd $HOME/demo
$ terraform init

output:

Initializing the backend...

Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Finding latest version of hashicorp/null...
- Installing hashicorp/null v3.
...
...

Generate Plan

Before we run a build, we’ll see what the plans looks like:

$ terraform plan

output:

...
...
Terraform will perform the following actions:

# null_resource.build_push_dkr_img will be created
+ resource "null_resource" "build_push_dkr_img" {
+ id = (known after apply)
+ triggers = {
+ "detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd"
}
}

Plan: 1 to add, 0 to change, 0 to destroy.

...

From the output of an initial plan, we can see the null_resource was triggered by the sha256 hash of the joined/concatenated contents of the docker artifacts.

Apply the Plan

Appy the plan to build and push to ECR:

$ terraform apply --auto-approve

Output :

null_resource.build_push_dkr_img: Creating...
null_resource.build_push_dkr_img: Provisioning with 'local-exec'...

null_resource.build_push_dkr_img (local-exec): Executing: ["/bin/sh" "-c" "docker build -t 111111111111.dkr.ecr.us-east-1.amazonaws.com/demo:latest \\\n -f ./docker-src/Dockerfile .\n\naws --profile demo_terraform ecr get-login-password --region us-east-1 | \\\n docker login --username AWS --password-stdin 111111111111.dkr.ecr.us-east-1.amazonaws.com\n\ndocker push 111111111111.dkr.ecr.us-east-1.amazonaws.com/demo:latest\n"]
null_resource.build_push_dkr_img (local-exec): #1 [internal] load build definition from Dockerfile
null_resource.build_push_dkr_img (local-exec): #1 transferring dockerfile: 95B done
null_resource.build_push_dkr_img (local-exec): #1 DONE 0.0s

null_resource.build_push_dkr_img (local-exec): #2 [internal] load .dockerignore
null_resource.build_push_dkr_img (local-exec): #2 transferring context: 2B done
null_resource.build_push_dkr_img (local-exec): #2 DONE 0.0s

null_resource.build_push_dkr_img (local-exec): #3 [internal] load build context
null_resource.build_push_dkr_img (local-exec): #3 transferring context: 70B done
null_resource.build_push_dkr_img (local-exec): #3 DONE 0.0s

null_resource.build_push_dkr_img (local-exec): #4 [1/1] COPY docker-src/demofile /
null_resource.build_push_dkr_img (local-exec): #4 CACHED

null_resource.build_push_dkr_img (local-exec): #5 exporting to image
null_resource.build_push_dkr_img (local-exec): #5 exporting layers done
null_resource.build_push_dkr_img (local-exec): #5 writing image sha256:03fd9c7b6c755b862c2a9fdf6bdba018c8ecc72a3a0b594fe23ca510d7422458 done
null_resource.build_push_dkr_img (local-exec): #5 naming to 111111111111.dkr.ecr.us-east-1.amazonaws.com/demo:latest done
null_resource.build_push_dkr_img (local-exec): #5 DONE 0.0s
null_resource.build_push_dkr_img (local-exec): WARNING! Your password will be stored unencrypted in /home/lts/.docker/config.json.
null_resource.build_push_dkr_img (local-exec): Configure a credential helper to remove this warning. See
null_resource.build_push_dkr_img (local-exec): https://docs.docker.com/engine/reference/commandline/login/#credentials-store

null_resource.build_push_dkr_img (local-exec): Login Succeeded
null_resource.build_push_dkr_img (local-exec): The push refers to repository [111111111111.dkr.ecr.us-east-1.amazonaws.com/demo]
null_resource.build_push_dkr_img (local-exec): 2fe8976e3c80: Preparing
null_resource.build_push_dkr_img (local-exec): 2fe8976e3c80: Layer already exists
null_resource.build_push_dkr_img (local-exec): latest: digest: sha256:8d17d369bb7831b67f42dd7861e15eaa26b68e079377d38c29884d467b09f223 size: 523
null_resource.build_push_dkr_img: Creation complete after 8s [id=2414129855178391836]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

trigged_by = {
"detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd"
}

As expected, the image is built and pushed to ECR.

  1. What happens if we run the apply again without making any changes to files within the docker artifacts directory ($HOME/demo/docker-src )?
$ terraform plan

output:

null_resource.build_push_dkr_img: Refreshing state... [id=2414129855178391836]

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

trigged_by = {
"detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd"
}

SHA256 hash is the same, i.e. no changes have been made to the docker image artifacts since last apply. Terraform takes no action.

2. What happens if we change a docker build artifact?

  • Let’s make a change to a file within the docker artifacts directory $HOME/demo/docker-src
  • We’ll change the contents of $HOME/demo/docker-src/demofile from :
demofile

to

demofile has changed
  • Now run terraform plan
$ terraform plan

output:

Terraform will perform the following actions:

# null_resource.build_push_dkr_img must be replaced
-/+ resource "null_resource" "build_push_dkr_img" {
~ id = "2414129855178391836" -> (known after apply)
~ triggers = { # forces replacement
~ "detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd" -> "da45847cea6180ae387eca5373cb0e0d40afacf180ad6b5b5c1fe43dfa7ac1e7"
}
}

Plan: 1 to add, 0 to change, 1 to destroy.

Changes to Outputs:
~ trigged_by = {
~ "detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd" -> "da45847cea6180ae387eca5373cb0e0d40afacf180ad6b5b5c1fe43dfa7ac1e7"
}
  • A rebuild of the image is expected as a result of the update we made to the contents of $HOME/demo/docker-src/demofile. The change in contents has changed the value of the calculated sha256 hash

from:
c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd
to
da45847cea6180ae387eca5373cb0e0d40afacf180ad6b5b5c1fe43dfa7ac1e7

  • Apply the plan and examine the output to confirm the image is rebuilt and pushed
$ terraform apply --auto-approve

3. What if we wanted to build/push the image regardless of whether or not the docker build artifacts have changed?

  • Getting back to the variable mentioned earlier force_image_rebuild:
variable "force_image_rebuild" {
type = bool
default = false
}
  • As we can see, the default value is “false”.
  • The trigger defined in the null resource is as follows:
triggers = {
detect_docker_source_changes = var.force_image_rebuild == true ? timestamp() : local.dkr_img_src_sha256
}
  • By overriding the default value of variable force_image_rebuild from false to true , we are effectively changing the null resource trigger from the sha256 hash calculation to timestamp()
  • Terraform would now build/push the image regardless of whether or not contents of $HOME/demo/docker-src have changed
  • To test if this works, run the following:
$ terraform apply -var "force_image_rebuild=true" --auto-approve

Terraform should build/push the image, with message in the output log that includes:

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

Outputs:

trigged_by = {
"detect_docker_source_changes" = "2023-mm-ddT06:29:28Z"
}

--

--

Tony Tannous

Learner. Interests include Cloud and Devops technologies.