Terraform – Dynamic Blocks

Terraform 0.12, a new feature which can be implemented in new projects

Recently HashiCorp published the newest version of Terraform, which has a very interesting feature as a new approach pertaining to loops. Most of us are familiar with the old count expression trick, which works a charm if we want to implement recurrence in our scripts. However, this approach will be complex as we dive deeper; for example, if we want to work with slightly more complicated configuration blocks inside the single resource instead of creating many resources. Right now we can do it because HashiCorp has introduced For and For-Each expressions.

Let’s take as a simple example script to create an S3 bucket from official Terraform documentation:

resource "aws_s3_bucket" "b" {
  bucket = "my-tf-test-bucket"
  acl    = "private"

  tags = {
    Name        = "My bucket"
    Environment = "Dev"
  }
}

There is no place for implementing additional features inside the script.If our case is more complicated, for example, if we want to implement transition property for objects placed in bucket, we will struggle with the lifecycle_rule. First of all, our Terraform friend doesn’t like emptiness, which in contrast is a desireable feature in automation.. Sometimes we want to create an S3 with one set of lifecycle_rule properties, sometimes with another. This is the exact situation where the expression from Terraform steps in. To understand this, let’s expand our script with the lifepolicy_rule:

 lifecycle_rule {
    prefix  = "config/"
    enabled = true

    noncurrent_version_transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }

    noncurrent_version_transition {
      days          = 60
      storage_class = "GLACIER"
    }

    noncurrent_version_expiration {
      days = 90
    }

In this form, such a script is not as reusable as it can be. It will prepare for us lifecycle_rule, which will permanently expect us to provide noncurrent_version_transition details equally for STANDARD_IA as well as GLACIER every time we use this script. As far as we consider our script to be “universal,” we should create it in a universal manner. As universal, I understand that with only a couple changes, preferably only variable changes, we should achieve our goals. So assume that we would like to use this script without GLACIER transition without commenting or leaving empty sections.  As I highlighted previously, it is not really possible because Terraform will force you to provide any value, otherwise it will fail. In this case, to be a bit more sophisticated, we could implement the for_each expression, which will allow us to be much more flexible. It will work as a simple ON/OFF switch. Let’s see what it could look like:

resource "aws_s3_bucket" "default" {
  bucket        = var.bucket_name
  acl           = var.acl
  region        = var.aws-region

  versioning {
    enabled = var.versioning_enabled
  }

  lifecycle_rule {
    id                                     = var.bucket_name
    prefix                                 = var.prefix
    enabled                                = var.lifecycle_rule_enabled
    abort_incomplete_multipart_upload_days = var.abort_incomplete_multipart_upload_days

    noncurrent_version_expiration {
      days = var.noncurrent_version_expiration_days
    }

    dynamic "noncurrent_version_transition" {
      for_each = var.enable_glacier_transition ? [1] : []

      content {
        days          = var.noncurrent_version_transition_days
        storage_class = "GLACIER"
      }
    }

    transition {
      days          = var.standard_transition_days
      storage_class = "STANDARD_IA"
    }

    dynamic "transition" {
      for_each = var.enable_glacier_transition ? [1] : []

      content {
        days          = var.glacier_transition_days
        storage_class = "GLACIER"
      }
    }

  }
}

In this case, we rely on the most important variable in this example:

var.enable_glacier_transition

This variable determines how our script behaves. Simply put, it will turn on or turn off two dynamic blocks where it is present:

    dynamic "noncurrent_version_transition" {
      for_each = var.enable_glacier_transition ? [1] : []

      content {
        days          = var.noncurrent_version_transition_days
        storage_class = "GLACIER"
      }
    }

    dynamic "transition" {
      for_each = var.enable_glacier_transition ? [1] : []

      content {
        days          = var.glacier_transition_days
        storage_class = "GLACIER"

Thanks to the combination with dynamic block, we can achieve our goal. If our variable equals to true the block will be present; if false it will be erased from terraform consciousness and S3 bucket will be created without GLACIER class rules.

Another example that will enhance our understanding is to simply create security groups with different settings determined by our variable:

variable "ingress_ports_list" {
  type        = list(number)
  description = "list of ingress ports to include in allow rule"
  default     = [80, 443]
}

Previously, we had to hardcode the same quantity of configuration blocks for as many ports as we would like to open in our Security Group:

resource "aws_security_group" "ec2" { 
  name        = "allow_particular_connections" 
  description = "Allow Particular Connections" 
  vpc_id      = var.VpcId 
 
  ingress { 
    from_port   = var.portToAllow 
    to_port     = var.portToAllow 
    protocol    = var.protocol 
    cidr_blocks = var.cidr 

  }

  ingress {
    from_port   = var.portToAllowSecond
    to_port     = var.portToAllowSecond
    protocol    = var.protocolSecond
    cidr_blocks = var.cidrSecond
  }

} 

We can also try to  “inject” ingress group rule into the source Security group, with aws_security_group_rule:

resource "aws_security_group_rule" "example" {
  type              = "ingress"
  from_port         = 0
  to_port           = 65535
  protocol          = "tcp"
  cidr_blocks       = aws_vpc.example.cidr_block
  security_group_id = "sg-123456"
}

But in this case we have to struggle with count expression and an additional list of ports, and taking the ports from list with count index and so on. However, we know that the shorter code we produce, the better performance will be achieved. That’s why the for_each expression will change the game:

resource "aws_security_group" "default" {
  name        = "sg_name"
  description = "sg_description"
  vpc_id      = "vpc_id"

  dynamic "ingress" {
    iterator = port
    for_each = var.ingress_ports
    content {
      from_port   = port.value
      to_port     = port.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

This will solve our problem with reusability of the script, because we can make almost every dynamic block dependent on determined variables values. There is no more commenting inside the the script or struggling with changing hardcoded configuration blocks.

This is a very simple example, but you should feel free to implement this principle in almost all cases where reusability is expected, which before the new terraform version, was very difficult to achieve.