Terraform Complex Types with AWS EC2 & Security Groups

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, andbool. 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
Go to AWS Console → EC2 → Instances
You should see two instances named
web-1andweb-2Both running
t3.microwith 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-0f12s43c3153bc79bwith 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:
Go to EC2 → Security Groups
You should see
Demo_SG_EC2with 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:
Click on either
web-1orweb-2Scroll to the Security tab
You should see
Demo_SG_EC2listed 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"]
}
}
💡
dynamicis 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
Always specify types —
type = stringis better than no type at all. It catches bugs early.Use validation blocks for business rules — Don't leave guardrails to chance.
Write meaningful error messages — "Must be t2.micro, t3.micro, or t3.small" is far more helpful than a cryptic type error.
Choose the right collection type — Need order? Use
list. Need uniqueness? Useset. Need key-value lookup? Usemap.Validate complex objects thoroughly — An
objecttype with validation on each field makes your configs bulletproof.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! 🙌



