Skip to main content

Command Palette

Search for a command to run...

Terraform Complex Types with AWS EC2 & Security Groups

Updated
11 min read
Terraform Complex Types with AWS EC2 & Security Groups
I

Software Engineer with hands-on project experience in building backend systems and web applications using Node.js, TypeScript, React, and Kubernetes.

Prerequisites: Make sure you've read the previous blogs in this series — especially the one on Terraform Type Constraints, where we covered primitive types like string, number, and bool. In this post, we level up to complex types and put them to real use by deploying EC2 instances and security groups on AWS.

Complex Type Constraints

Before we dive into code, let's understand each complex type with simple analogies.

The 5 Complex Types at a Glance

1. list

Think of it like: A numbered waiting list at a coffee shop. Person 1 is first, Person 2 is second. You can have the same person twice. Order matters.

variable "instance_names" {
  description = "List of instance names"
  type        = list(string)
  default     = ["web-1", "web-2"]
}
  • ✅ Ordered (index 0, 1, 2...)

  • ✅ Allows duplicates

  • ✅ All items must be the same type

  • Access: var.instance_names[0]"web-1"

2. set

Think of it like: A bag of unique marbles. You can't have two identical marbles, and there's no "first" or "second" — just a collection.

variable "availability_zones" {
  type    = set(string)
  default = ["ap-south-1a", "ap-south-1b"]
}
  • ✅ No duplicates allowed

  • ❌ No index access

  • ✅ Good for unique identifiers

  • Accessed by converting it into a list, and using index: tolist(var.availability_zones)

3. map

Think of it like: A contact book. Each name (key) maps to a phone number (value). You look up entries by name.

variable "instance_tags" {
  description = "Map of tags applied to the instance"
  type        = map(string)
  default = {
    Environment = "dev"
    Project     = "terraform-demo"
  }
}
  • ✅ Key-value pairs

  • ✅ All values must be the same type

  • ✅ Access by key: var.instance_tags["Environment"]"dev"

4. tuple

Think of it like: A recipe card with exactly 3 fields: ingredient name, quantity, and whether it's optional. The order and count are fixed, but each slot can be a different type.

variable "instance_config" {
  description = "Tuple containing instance configuration"
  type        = tuple([string, number, bool])
  default     = ["t3.micro", 20, true]
}
  • ✅ Fixed number of elements

  • ✅ Each element can be a different type

  • ✅ Access by index: var.instance_config[0]"t3.micro"

5. object

Think of it like: A job application form. It has specific named fields — name, age, employed — and each field has its own type.

variable "server_settings" {
  description = "Object representing structured server settings"
  type = object({
    instance_type = string
    monitoring    = bool
    volume_size   = number
  })
  default = {
    instance_type = "t3.micro"
    monitoring    = true
    volume_size   = 20
  }
}
  • ✅ Named attributes (unlike tuples)

  • ✅ Each attribute can be a different type

  • ✅ Access by attribute: var.server_settings.instance_type"t3.micro"


Project Structure

Here's the file layout for our demo:

.
├── main.tf        # Resources (EC2, Security Groups)
├── output.tf      # What to display after apply
├── provider.tf    # AWS provider config
└── variables.tf   # All our complex type variables

provider.tf

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

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

variables.tf

variable "instance_names" {
  description = "List of instance names"
  type        = list(string)
  default     = ["web-1", "web-2"]
}

variable "instance_tags" {
  description = "Map of tags applied to the instance"
  type        = map(string)
  default = {
    Environment = "dev"
    Project     = "terraform-demo"
  }
}

variable "instance_config" {
  description = "Tuple containing instance configuration"
  type        = tuple([string, number, bool])
  default     = ["t3.micro", 20, true]
}

variable "server_settings" {
  description = "Object representing structured server settings"
  type = object({
    instance_type = string
    monitoring    = bool
    volume_size   = number
  })
  default = {
    instance_type = "t3.micro"
    monitoring    = true
    volume_size   = 20
  }
}

main.tf

resource "aws_instance" "demo_ec2" {
  ami           = "ami-0f559c3642608c138"
  instance_type = var.server_settings.instance_type
  count         = length(var.instance_names)

  monitoring = var.server_settings.monitoring

  tags = merge(
    var.instance_tags,
    { Name = var.instance_names[count.index] }
  )

  root_block_device {
    volume_size = var.server_settings.volume_size
  }
}

output.tf (optional)

output "instance_names" {
  value = var.instance_names
}

output "tags" {
  value = var.instance_tags
}

output "tuple_example" {
  value = var.instance_config
}

output "object_example" {
  value = var.server_settings
}

How Does This Create Two EC2 Instances?

The magic is in this line:

count = length(var.instance_names)

var.instance_names is ["web-1", "web-2"] — a list with 2 items. length() returns 2, so Terraform creates 2 EC2 instances.

Then for each instance, it uses count.index (0 or 1) to pick the right name from the list:

{ Name = var.instance_names[count.index] }

The merge() function combines the base tags from var.instance_tags with the Name tag.

Now let's actually deploy this to AWS. Run these commands step by step:

terraform init

terraform plan
# You can see "Plan: 2 to add, 0 to change, 0 to destroy." in output

terraform apply -auto-approve

Verify in AWS Console

  1. Go to AWS Console → EC2 → Instances

  2. You should see two instances named web-1 and web-2

  3. Both running t3.micro with a 20GB root volume

What is a VPC?

When you click on one of your EC2 instances in the AWS Console, you'll notice a VPC ID listed. Let's demystify this.

A Virtual Private Cloud (VPC) is like your own private network inside a cloud provider. Imagine renting a secure section of a huge shared data centre where you control the network layout, like choosing IP ranges, creating subnets, and deciding which servers can communicate with each other or access the internet.

Even though the physical infrastructure is shared with many other users, your VPC is logically isolated, so your applications and data stay private and secure. This lets you run cloud servers, databases, and services as if they were inside your own controlled company network, but without managing physical hardware.

Why the Default VPC?

AWS automatically creates a Default VPC in each region when you sign up. This default VPC is pre-configured with:

  • Public subnets in each availability zone

  • An internet gateway (so your instances can reach the internet)

  • Sensible default routing

For our demo, we're using the default VPC — it's already set up and ready to go. In production, you'd create custom VPCs with more controlled networking, but for learning Terraform, the default VPC keeps things simple.

💡 Pro tip: You can find your default VPC ID in AWS Console → VPC → Your VPCs. Look for the one with "Default VPC: Yes".
Or you can find the default VPC ID in your EC2 details as well.

Security Groups

Now that our instances are running, they need protection. That's where Security Groups come in.

Think of it like: A Security Group is a bouncer at a nightclub. It has a list of who's allowed in (inbound rules) and who can leave (outbound rules). Anyone not on the list gets turned away.

Inbound vs Outbound Rules

Rule Type Direction Purpose
Ingress Internet → EC2 Control who can connect TO your instance
Egress EC2 → Internet Control what your instance can connect TO

Adding the Security Group to main.tf

Add this below your existing EC2 resource block:

resource "aws_security_group" "demo_sg" {
  name        = "demo-security-group"
  description = "Simple security group"

  vpc_id      = "vpc-0f12s43c3153bc79b" # Replace with your default VPC ID

  # Allow SSH from anywhere
  ingress {
    description      = "SSH"
    from_port        = 22
    to_port          = 22
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  # Allow HTTP from anywhere
  ingress {
    description      = "HTTP"
    from_port        = 80
    to_port          = 80
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  # Allow HTTPS from anywhere
  ingress {
    description      = "HTTPS"
    from_port        = 443
    to_port          = 443
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  # Allow ALL outbound traffic
  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"   # -1 means ALL protocols
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Name = "Demo_SG_EC2"
  }
}

⚠️ Replace vpc-0f12s43c3153bc79b with your own default VPC ID from the AWS Console.

💡 What is 0.0.0.0/0? It means "all IPv4 addresses" — i.e., anyone on the internet.
In production, you'd restrict this to specific IPs.

Apply changes:

terraform plan
terraform apply -auto-approve

You should see:

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

Verify in AWS Console:

  1. Go to EC2 → Security Groups

  2. You should see Demo_SG_EC2 with the 3 inbound rules and 1 outbound rule

Attaching the Security Group to EC2 Instances

Right now the security group exists, but it's not connected to our instances. Let's fix that.

Update the aws_instance resource in main.tf:

resource "aws_instance" "demo_ec2" {
  ami           = "ami-0f559c3642608c138"
  instance_type = var.server_settings.instance_type
  count         = length(var.instance_names)

  # 👇 This line attaches the security group!
  vpc_security_group_ids = [aws_security_group.demo_sg.id]

  monitoring = var.server_settings.monitoring

  tags = merge(
    var.instance_tags,
    { Name = var.instance_names[count.index] }
  )

  root_block_device {
    volume_size = var.server_settings.volume_size
  }
}

aws_security_group.demo_sg.id is a resource reference. Terraform automatically reads the ID of the security group it just created. No copy-pasting IDs needed!

Apply Changes:

terraform plan
terraform apply -auto-approve

Terraform is smart. It knows the instances already exist and will only modify them to attach the security group:

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

Verify in the AWS Console:

  1. Click on either web-1 or web-2

  2. Scroll to the Security tab

  3. You should see Demo_SG_EC2 listed under Security Groups ✅

Dynamic Blocks with map Type

The security group we wrote above works fine, but imagine you had 10 ports to allow. Writing 10 ingress blocks would be tedious and repetitive.

Enter Dynamic Blocks! Combined with a map variable, this is incredibly powerful.

Step 1: Add the Variable

variable "security_rules" {
  type = map(number)

  default = {
    SSH   = 22
    HTTP  = 80
    HTTPS = 443
  }
}

Step 2: Use a Dynamic Block in the Security Group

resource "aws_security_group" "demo_sg_map" {
  name = "map-sg"

  dynamic "ingress" {
    for_each = var.security_rules  # Loop over each key-value pair

    content {
      description      = ingress.key    # "SSH", "HTTP", "HTTPS"
      from_port        = ingress.value  # 22, 80, 443
      to_port          = ingress.value
      protocol         = "tcp"
      cidr_blocks      = ["0.0.0.0/0"]
      ipv6_cidr_blocks = ["::/0"]
    }
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}

💡 dynamic is a for-loop that writes Terraform blocks for you. You give it a collection, and it generates one block per item.

To add a new port? Just add one line to security_rules:

postgres = 5432

No touching the security group resource at all! 🎉

Variable Validation

What if someone tries to deploy an expensive instance type like m5.large when the project only allows small instances? Terraform's validation blocks let you enforce rules and show helpful error messages.

variable "instance_type" {
  type        = string
  description = "EC2 instance type"

  validation {
    condition     = contains(["t2.micro", "t3.micro", "t3.small"], var.instance_type)
    error_message = "Instance type must be one of: t2.micro, t3.micro, t3.small."
  }
}

What contains() does: Checks if the value exists in the allowed list. If not, Terraform refuses to proceed.

If someone runs:

terraform apply -var="instance_type=m5.large"

They get:

╷
│ Error: Invalid value for variable
│
│   on variables.tf line 1, in variable "instance_type":
│    1: variable "instance_type" {
│
│ Instance type must be one of: t2.micro, t3.micro, t3.small.
╵

💡 Why this matters: Validation blocks act as guardrails. In a team environment, they prevent junior engineers from accidentally spinning up expensive resources. It's like a form validator on a web app — catch the mistake before it happens.

Best Practices for Type Constraints

  1. Always specify typestype = string is better than no type at all. It catches bugs early.

  2. Use validation blocks for business rules — Don't leave guardrails to chance.

  3. Write meaningful error messages — "Must be t2.micro, t3.micro, or t3.small" is far more helpful than a cryptic type error.

  4. Choose the right collection type — Need order? Use list. Need uniqueness? Use set. Need key-value lookup? Use map.

  5. Validate complex objects thoroughly — An object type with validation on each field makes your configs bulletproof.

  6. Document type requirements in descriptions — Someone will thank you (probably yourself in 6 months).

Clean Up (Don't Forget!)

To avoid AWS charges, tear down everything with:

terraform destroy -auto-approve

This removes all resources Terraform created. You'll see:

Destroy complete! Resources: 3 destroyed.

Found this helpful? Subscribe to my newsletter to get updates and next blog on this series. Also share with someone learning DevOps! 🙌