🛠️ Chapter 2 – Terraform Basics
📁 What to Track in Git
When managing infrastructure with Terraform, it’s best practice to commit all configuration (*.tf
) files to version control — usually Git.
However, Terraform also generates files that should not be committed:
.terraform/
*.tfstate
*.tfstate.backup
These files can contain sensitive information such as credentials, private IPs, or internal metadata. They are specific to a local or remote state and should never be shared or versioned.
To exclude them, use a .gitignore
file:
.terraform/
*.tfstate
*.tfstate.backup
🔗 Learn more: Getting started with .gitignore
🔄 Updating Infrastructure
When you update a Terraform config file and re-apply it, Terraform uses the state file to compare current vs desired infrastructure.
By default, Terraform performs in-place updates when possible (e.g., changing an instance type).
However, some changes (like user_data
) require destroying and recreating the resource. To enforce this behavior explicitly:
resource "aws_instance" "example" {
ami = "ami-12345678"
instance_type = "t2.micro"
user_data = file("setup.sh")
user_data_replace_on_change = true
}
This ensures a new user_data
script triggers instance replacement.
💡 Use this with care — destruction might cause downtime or loss of ephemeral data.
🔢 Terraform Expressions
A Terraform expression is any snippet of code that returns a value. There are different types:
✅ Literals
"t2.micro"
true
42
✅ References (Dynamic)
aws_instance.example.public_ip
This format is:
<provider>_<type>.<resource_name>.<attribute>
These references establish implicit dependencies — Terraform builds a graph to ensure resources are created in the correct order.
🧱 Full Example main.tf
(with ASG)
provider "aws" {
region = "us-east-2"
}
resource "aws_instance" "example" {
ami = "ami-0fb653ca2d3203ac1"
instance_type = "t2.micro"
user_data = <<-EOF
#!/bin/bash
echo "Hello, World!" > index.html
nohup busybox httpd -f -p 8080 &
EOF
user_data_replace_on_change = true
vpc_security_group_ids = [
aws_security_group.allow_http.id,
aws_security_group.allow_ssh.id
]
tags = {
Name = "Terraform_Example_Instance"
}
}
resource "aws_security_group" "allow_http" {
name = "allow_http"
description = "Allow HTTP traffic on port 8080"
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "Allow_HTTP"
}
}
resource "aws_security_group" "allow_ssh" {
name = "allow_ssh"
description = "Allow SSH traffic on port 22"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "Allow_SSH"
}
}
🧠 ASG Note
In production, we rarely deploy single EC2 instances directly.
Instead, we use Auto Scaling Groups (ASGs) to create, replace, or destroy instances automatically behind the scenes. These are usually fronted by a Load Balancer (ALB/NLB).
Terraform can still manage individual EC2 instances (useful for labs or prototypes), but for real-world infra, ASG + ALB is the standard pattern.
📤 Outputs
Terraform lets you expose useful data after apply
via output values.
Example:
output "instance_ip" {
value = aws_instance.example.public_ip
}
After applying:
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
instance_ip = "18.223.245.12"
This is useful for:
- CI/CD pipelines (consume outputs in scripts)
- Sharing information between modules
- Debugging
🔍 Terraform also allows
sensitive = true
to prevent secrets (like passwords) from being displayed in CLI output.
🔒 Lifecycle Management
Terraform lets you customize how it handles specific resources using the lifecycle
block.
Example: Prevent deletion
resource "aws_instance" "example" {
# config...
lifecycle {
prevent_destroy = true
}
}
This prevents accidental deletion — even if you run terraform destroy
.
⚠️ You must remove the lifecycle block before Terraform will allow you to destroy that resource.
Additional Lifecycle Settings
create_before_destroy = true
: Avoids downtime by provisioning a new resource before destroying the old one.ignore_changes
: Tells Terraform to ignore updates to specific attributes, often used when an external system modifies a resource.
lifecycle {
ignore_changes = [tags["last_updated_by"]]
}
🔍 terraform show
The command:
terraform show
Displays the full state of the deployed infrastructure — including IDs, IPs, tags, and metadata.
Useful for:
- Debugging
- Auditing
- Confirming what exists vs what’s in the
.tf
files
You can also use:
terraform show -json > state.json
This dumps machine-readable state data for use in automation or CI pipelines.
🚀 EC2 Clusters and Lifecycle
In real-world deployments, we rarely create standalone EC2 instances. Instead, we use Auto Scaling Groups (ASGs) to manage instance clusters that automatically scale based on demand.
To define an ASG in Terraform, we typically use a Launch Template (or the legacy Launch Configuration) to define the instance settings.
⚠️ Note: Tags are not defined in the launch template, but in the ASG itself.
🛠️ Launch Template Example
resource "aws_launch_template" "example" {
name_prefix = "web-"
image_id = "ami-0abc1234"
instance_type = "t2.micro"
user_data = <<-EOF
#!/bin/bash
echo "Hello from LC" > /var/www/html/index.html
EOF
}
🛠️ ASG Example
resource "aws_autoscaling_group" "example" {
name = "example-asg"
min_size = 1
max_size = 3
desired_capacity = 2
vpc_zone_identifier = ["subnet-abc123", "subnet-def456"]
health_check_type = "EC2"
health_check_grace_period = 300
force_delete = true
launch_template {
id = aws_launch_template.example.id
version = "$Latest"
}
tag {
key = "Name"
value = "terraform-asg-example"
propagate_at_launch = true
}
lifecycle {
create_before_destroy = true
}
}
♻️ Why create_before_destroy
?
Launch Templates are immutable — changing one creates a replacement. Since ASGs reference the template, the old one can’t be deleted until the ASG is updated.
Terraform normally deletes first, then creates. But this would fail due to the dependency chain.
Using:
lifecycle {
create_before_destroy = true
}
Tells Terraform to reverse the flow: create the new template, update dependencies, then destroy the old one.
This avoids the classic chicken-and-egg problem.
🧮 ASGs and Expressions
When defining an ASG, you must associate it with subnets (via vpc_zone_identifier
).
In the earlier example, the subnets were hardcoded. While acceptable in labs, in production it’s better to dynamically fetch subnet IDs using expressions:
data "aws_subnets" "example" {
filter {
name = "tag:Environment"
values = ["dev"]
}
}
resource "aws_autoscaling_group" "example" {
vpc_zone_identifier = data.aws_subnets.example.ids
...
}
This approach ensures portability and avoids tight coupling with hardcoded values.