The path below takes you straight to practical Terraform knowledge. You learn how to make infrastructure repeatable, transparent and versionable with Infrastructure as Code. Clear examples take you from the base concepts to a first working AWS environment.
Your learning outcome is the point: every step is explained, you hear why it matters, what to watch for, and how to use it later in production.
⚠️ Important note: The material is aimed at experienced Linux administrators, junior DevOps engineers and IT professionals with solid Linux and networking knowledge. If you are comfortable on the command line and already understand cloud concepts such as virtual machines, networks and IAM, you are in the right place. Terraform newcomers are welcome, but Linux beginners should complete basic tutorials first so you can focus on the essentials.
What is Terraform?
Terraform is an open-source tool from HashiCorp that enables Infrastructure as Code. You describe your entire infrastructure in declarative configuration files — from virtual machines through networks to DNS records. Terraform translates those descriptions into API calls at cloud providers or other services and runs them in the right order. Every step becomes documented, versioned and automatable.
With Terraform you no longer type by hand in a console; you define the desired infrastructure state in text. Terraform then plans the required changes, shows you a preview of which resources will be created, changed or deleted, and then executes the actions.
Why should you know Terraform?
In modern IT teams, speed matters as much as reliability. Manual setup through web consoles is error-prone, time-consuming and not reproducible. Terraform removes those hurdles by making infrastructure describable, testable and auditable. Teams get fewer surprises in production and more consistency across development, staging and production environments.
Terraform is also cloud-agnostic. With the same approach you can manage resources in AWS, Azure, Google Cloud or on-premises. The large number of providers lets you control not only cloud instances but also DNS services, monitoring tools or even GitHub repos with Terraform. That makes Terraform the universal tool in a heterogeneous infrastructure world.
Goals of this material: By the end you will be able to set up Terraform projects in a structured way and understand the core concepts. You will handle HCL syntax for realistic infrastructure scenarios and build your first AWS infrastructure with Terraform. Practical examples help you apply what you learned immediately in day-to-day work. After this fundamentals piece you are ready for advanced topics such as state management, best practices and complex multi-cloud scenarios.
Ready to turn your infrastructure into code?
The next section lays the foundation of Infrastructure as Code and shows how manual administration differs from modern DevOps approaches.
Infrastructure as Code
Traditional vs. modern infrastructure management
Be honest: We have all tried at 2 a.m. to repair a server we “quickly” stood up through the web console six months ago. And we have all asked: How on earth did I configure that back then? That is exactly where Infrastructure as Code starts, and it fundamentally solves the problems we have every day with traditional infrastructure management.
The traditional way: click, hope, forget:
How does traditional infrastructure management work?
You log into the AWS console, Azure portal or vSphere client and click through endless menus. For a simple web-server infrastructure that means: create an EC2 instance, configure security groups, set up a load balancer, stand up RDS, Route 53 for DNS. Every step is done by hand, often with many clicks and drop-down menus.
💡 What happens in practice? You take a screenshot of the most important settings (if you remember), maybe write a few notes in a wiki or a text file, and hope you still know what you did next time. Often nothing is documented at all — “I’ll do that later” is the classic.
Practical example:
You set up a staging environment for your team. That means:
┌─────────────────────────────────────────────────────────────┐
│ Manual AWS console workflow │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Login AWS Management Console (MFA, browser) │
│ 2. Create VPC: CIDR 10.0.0.0/16, DNS resolution │
│ 3. Create subnets: public and private subnets │
│ 4. Create internet gateway and attach to VPC │
│ 5. Configure route tables (0.0.0.0/0 -> IGW) │
│ 6. Define security groups (HTTP, HTTPS, SSH) │
│ 7. Launch EC2 instances (AMI, type, key pair) │
│ 8. Set up load balancer (ALB, target groups) │
│ 9. Create RDS database (engine, Multi-AZ, storage) │
│ │
│ Result: ~3 hours effort, error-prone and manual │
│ │
└─────────────────────────────────────────────────────────────┘
Why is that a problem? After three hours of clicking you have a working environment. But what happens when a colleague needs an identical development environment? They have to repeat every step, and they will certainly make different settings. The result: environments that differ in subtle ways and cause mysterious errors.
Traditional workflow:
┌─────────────────────────────────────────────────────────────┐
│ Traditional workflow: timeline │
├─────────────────────────────────────────────────────────────┤
│ │
│ Day 1: Click, configure, test [OK] │
│ Day 30: "How was that configured again?" [UNCLEAR] │
│ Day 60: "Colleague needs a copy" -> 3h work [DRIFT] │
│ Day 90: Disaster recovery -> 6h rebuild [ERROR] │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Result: knowledge silos, inconsistency and high cost │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
⚠️ Typical pitfalls of the traditional approach:
- Snowflake servers: Every server is unique and not reproducible.
- Undocumented changes: Who changed what when? No idea!
- Inconsistent environments: Dev, staging and prod are subtly different.
- Long recovery times: On failure everything has to be rebuilt by hand.
- Knowledge silos: Only one person knows how the infrastructure works.
The modern way: write code, version it, automate:
How does Infrastructure as Code work?
You describe the desired infrastructure in text files. Those files contain all information about your servers, networks, databases and other resources. A tool such as Terraform reads the files, compares them with the current state and automatically runs the required changes.
💡 The decisive difference: You describe the what, not the how. Instead of “Go to EC2, click Launch Instance, choose Ubuntu…” you write “I want an Ubuntu instance with 2 GB RAM in the us-west-2 region”.
The same example with Infrastructure as Code:
# Create VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "staging-vpc"
}
}
# Public Subnet
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-west-2a"
map_public_ip_on_launch = true
tags = {
Name = "staging-public-subnet"
}
}
💡 What happens here? You write this configuration once, commit it to Git, and anyone can create an identical environment with a single
terraform apply. The infrastructure is built exactly as you described it.
IaC workflow:
┌─────────────────────────────────────────────────────────────┐
│ IaC workflow: timeline │
├─────────────────────────────────────────────────────────────┤
│ │
│ Day 1: Write HCL, validate, commit [OK] │
│ Day 30: Check git history -> immediately clear [OK] │
│ Day 60: Colleague runs terraform apply (5m) [OK] │
│ Day 90: New region -> change a variable (10m) [OK] │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Result: 100% reproducibility and transparency │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
The fundamental advantages:
┌─────────────────────────────────────────────────────────────┐
│ Fundamental advantages of Terraform │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 1. Declarative: Define target state (what/how) │ │
│ ├───────────────────────────────────────────────────────┤ │
│ │ 2. Versioned: Full history in the Git repo │ │
│ ├───────────────────────────────────────────────────────┤ │
│ │ 3. Reproducible: Identical staging and prod envs │ │
│ ├───────────────────────────────────────────────────────┤ │
│ │ 4. Testable: Linting, validation and CI/CD │ │
│ ├───────────────────────────────────────────────────────┤ │
│ │ 5. Documented: Code is the only single source │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Why is this paradigm shift so important?
Speed: New environments appear in minutes instead of hours. Your team can focus on application development instead of wasting time on repetitive infrastructure work.
Consistency: Every environment is created from the same code. Development, staging and production are guaranteed to be configured identically. That eliminates the notorious “works on my machine” problems.
Traceability: In Git you see who made which infrastructure change when. On problems you can return to the previous version immediately.
Practical example of the paradigm shift:
- Traditional: “Can you show me how you configured the load balancer?”
- Modern: “Look at the file
load-balancer.tf, everything is in there.”
⚠️ Important insight: Switching to IaC is not only a technical upgrade, but a cultural change. Teams work more collaboratively, changes are reviewed like code, and infrastructure becomes part of the development process.
Ready for the concrete benefits? You now know the fundamental difference between old and new infrastructure management. The next part shows how these theoretical concepts pay off in day-to-day work.
Advantages of IaC in practice
Now that you understand the basic difference between traditional and modern infrastructure management, here are the concrete benefits Infrastructure as Code brings to day-to-day work. These are not only theoretical concepts; they solve real problems you face as a Linux administrator or DevOps engineer every day.
Reproducibility: identical environments guaranteed:
Why does that matter?
You know the problem: Your application works perfectly in development, but mysterious errors appear in production. Often that is due to subtle differences in infrastructure configuration — other OS versions, different network settings or divergent security-group rules.
🔧 Practical example:
- Development: Quickly stand up a t2.micro instance with MySQL 5.7
- Staging: t2.small instance with MySQL 8.0 (because that happened to be available)
- Production: t3.medium instance with MySQL 8.0 and RDS Multi-AZ
Result: Three different environments, three different sources of problems.
With Infrastructure as Code you define the desired configuration once:
resource "aws_db_instance" "main" {
engine = "mysql"
engine_version = "8.0"
instance_class = var.db_instance_class
allocated_storage = var.db_storage
db_name = var.db_name
username = var.db_username
password = var.db_password
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
backup_retention_period = var.backup_retention
backup_window = "03:00-04:00"
tags = {
Name = "${var.environment}-database"
}
}
The difference: Through variables (var.db_instance_class) you can size environments differently, but the base configuration stays identical. Development gets a small instance, production a larger one — but both use the same MySQL version, the same backup settings and the same security configuration.
💡 Why does that work so well? Terraform creates an execution plan that is deterministic. The same code always leads to the same infrastructure, no matter who runs it or when.
Versioning: your infrastructure in Git:
What does versioning mean for infrastructure? Your Terraform files are managed in Git just like application code. Every change is tracked, you see who changed what when, and on problems you can simply return to the previous version.
Why is that revolutionary? Imagine you change security-group rules for production and suddenly users cannot log in. With traditional management you would click frantically through the AWS console and try to remember which settings you changed.
🔧 Practical example with a Git workflow:
# Show recent changes
git log --oneline -10
a1b2c3d Update security group rules for web tier
d4e5f6g Add new RDS instance for analytics
g7h8i9j Update ALB target group health check
# Problem in production? Revert to the previous version
git revert a1b2c3d
terraform plan # Shows what will be undone
terraform apply # Runs the rollback
The workflow looks like this:
- You change the Terraform configuration
- You commit the change with a meaningful commit message
- A colleague reviews your change (pull request)
- After the merge the infrastructure is updated automatically
- On problems:
git revertand the infrastructure is back in the old state
⚠️ Important note: Versioning only works when every infrastructure change is made through code. Manual changes in the console bypass the version system and can cause problems.
Documentation: the code is the truth:
What is the problem with traditional documentation? Wiki pages are never updated, Confluence documents are stale, and notes in a text file are cryptic. Reality always diverges from the documentation.
How does IaC solve that? The code is the documentation. When you want to know how your infrastructure is configured, you look at the Terraform files. They are always current, because they define the infrastructure.
🔧 Practical example:
# Web-tier security group
resource "aws_security_group" "web" {
name_prefix = "${var.environment}-web-"
vpc_id = aws_vpc.main.id
# HTTP traffic from anywhere
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# HTTPS traffic from anywhere
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# SSH only from the management subnet
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [aws_subnet.management.cidr_block]
}
# Outbound traffic fully allowed
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.environment}-web-sg"
Tier = "web"
}
}
Why is that so much better?
This code block shows you at a glance:
- Which ports are open and why
- Where traffic may come from
- How resources are named and tagged
- How they are connected to other resources
💡 Extra advantage: Modern IDEs can analyse Terraform code and immediately show which resources depend on each other. That is better than any traditional documentation.
Automation and CI/CD integration:
What does infrastructure in CI/CD mean? Your infrastructure changes go through the same quality process as application code. Pull requests, code reviews, automatic tests and staged deployments become the norm.
Why is that so valuable? Infrastructure changes are often riskier than code changes. An error in the network configuration can take down the entire application. With CI/CD integration you can minimise those risks.
🔧 Practical example of a GitLab CI pipeline:
stages:
- validate
- plan
- apply
terraform-validate:
stage: validate
script:
- terraform init
- terraform validate
- terraform fmt -check
terraform-plan:
stage: plan
script:
- terraform plan -out=tfplan
artifacts:
paths:
- tfplan
terraform-apply:
stage: apply
script:
- terraform apply tfplan
when: manual
only:
- main
The workflow:
- You push your Terraform changes
- The pipeline validates the syntax automatically
- A plan is created and stored as an artifact
- A maintainer reviews the plan and triggers the deployment manually
⚠️ Security aspect: Automatic deployments to production are possible, but should be well thought through. A manual approval step is often sensible for critical environments.
Cost control: visibility and cleanup:
Why is cost control easier with IaC? With Terraform you see at a glance which resources are provisioned. You can shut down development environments at the end of the day and bring them back up the next morning.
🔧 Practical example:
# Shut down the development environment at the end of the day
terraform destroy -target=aws_instance.dev_servers
# Bring it back up the next morning
terraform apply -target=aws_instance.dev_servers
Extended cost control:
- Terraform can be integrated with tools such as Infracost to estimate costs before deployment
- Automatic cleanup jobs can identify unused resources
- Resource tagging is enforced consistently
💡 Practical tip: Use Terraform workspaces for different environments. That way you can manage individual environments without affecting others.
You see the advantages of Infrastructure as Code. But which tool should you choose? The next part compares Terraform with its main competitors honestly.
Terraform vs. other IaC tools
Ansible, CloudFormation, Pulumi
You will ask why Terraform of all things, and not one of the other Infrastructure as Code tools. The landscape is large, and every tool has its place. The main alternatives are compared honestly below so you understand where Terraform shines and where other tools may fit better.
Terraform vs. Ansible:
Infrastructure vs. configuration management
What is the fundamental difference? Terraform and Ansible solve different problems: Terraform creates and manages infrastructure (servers, networks, databases), while Ansible configures that infrastructure (install software, start services, adjust configs).
| Criterion | Terraform | Ansible |
|---|---|---|
| Primary purpose | Infrastructure provisioning | Configuration management |
| Approach | Declarative | Procedural |
| State management | Yes, intelligent | No, idempotent |
| Dependencies | Automatic resolution | Manual order |
| Architecture | Agentless | Agentless (SSH) |
| Learning curve | Low for infrastructure | Low for configuration |
| Strengths | Resource lifecycle | Software deployment |
| Community | Large, provider-focused | Very large, role-focused |
Typical split of work in practice:
- Terraform creates: AWS VPC and subnets, EC2 instances, RDS database, load balancer
- Ansible configures: Docker installation, application deployment, SSL certificates, monitoring agents, backup scripts
Why do the two tools complement each other perfectly? In practice you often use both: Terraform for infrastructure provisioning, Ansible for configuration afterwards. Terraform can even run Ansible playbooks after resource creation.
🔧 Practical example — integration:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1d0"
instance_type = "t3.micro"
provisioner "remote-exec" {
inline = [
"sudo apt update",
"sudo apt install -y python3"
]
}
provisioner "local-exec" {
command = "ansible-playbook -i '${self.public_ip},' webserver.yml"
}
}
When should you use which?
| Task | Tool | Reason |
|---|---|---|
| Create VPC | Terraform | Infrastructure provisioning |
| Install Docker | Ansible | Software configuration |
| RDS database | Terraform | Managed service |
| SSL certificates | Ansible | Filesystem operations |
| Load balancer | Terraform | Infrastructure resource |
| Application deploy | Ansible | Deployment workflow |
💡 Practical tip: Many teams use a “Terraform-first” approach for infrastructure and use Ansible only for complex configuration tasks that Terraform does not cover elegantly.
⚠️ Important boundary: Do not use Terraform for configuration management and do not use Ansible for infrastructure provisioning. Each tool is clearly more effective in its specialism.
Terraform vs. CloudFormation: multi-cloud vs. AWS-native:
What is CloudFormation? AWS CloudFormation is Amazon’s native Infrastructure as Code tool. It is deeply integrated into the AWS world and supports practically every AWS service immediately after publication.
| Criterion | Terraform | CloudFormation |
|---|---|---|
| Cloud support | Multi-cloud (3000+ providers) | AWS exclusive |
| Syntax | HCL (compact) | JSON/YAML (verbose) |
| Preview | terraform plan | Change Sets |
| State management | External state file | AWS-managed |
| Cost | Open source free | Free |
| Support | Community | AWS support |
| New features | Delay at providers | Immediately available |
| Rollback | Manual | Automatic |
| Module system | Very mature | Nested stacks |
| Vendor lock-in | Low | High |
Performance comparison:
| Aspect | Terraform | CloudFormation |
|---|---|---|
| Deployment speed | Medium | Fast |
| Error handling | Good | Very good |
| Retry mechanisms | Yes | Yes |
| Parallelisation | Intelligent | Limited |
| Resource limits | Provider-dependent | AWS limits |
🔧 Practical example — syntax comparison:
CloudFormation (YAML):
Resources:
WebServer:
Type: AWS::EC2::Instance
Properties:
ImageId: ami-0c55b159cbfafe1d0
InstanceType: t3.micro
KeyName: !Ref KeyName
SecurityGroups:
- !Ref WebServerSecurityGroup
Tags:
- Key: Name
Value: WebServer
Terraform (HCL):
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1d0"
instance_type = "t3.micro"
key_name = var.key_name
security_groups = [aws_security_group.web.name]
tags = {
Name = "WebServer"
}
}
Multi-cloud example with Terraform:
# AWS resources
resource "aws_instance" "web" {
provider = aws.us_east_1
ami = "ami-0c55b159cbfafe1d0"
instance_type = "t3.micro"
}
# Azure resources
resource "azurerm_virtual_machine" "web" {
provider = azurerm.west_europe
name = "web-vm"
location = "West Europe"
}
# DNS at Cloudflare
resource "cloudflare_record" "web" {
zone_id = var.cloudflare_zone_id
name = "web"
value = aws_instance.web.public_ip
type = "A"
}
Decision matrix:
| Scenario | Recommendation | Reason |
|---|---|---|
| Pure AWS environment | CloudFormation | Native integration, AWS support |
| Multi-cloud | Terraform | Uniform syntax |
| Avoid vendor lock-in | Terraform | Cloud-agnostic |
| Maximum AWS integration | CloudFormation | New features first |
| Complex modules | Terraform | Better module system |
| Enterprise support | CloudFormation | AWS-backed |
💡 Migration strategy: Many companies start with CloudFormation, then switch to Terraform as soon as they use additional cloud providers or services outside AWS. The switch is possible, but expensive.
Terraform vs. Pulumi: HCL vs. real programming languages:
What makes Pulumi different? Pulumi uses real programming languages such as Python, TypeScript, Go or C# for Infrastructure as Code. You write infrastructure in the language you already know.
| Criterion | Terraform | Pulumi |
|---|---|---|
| Language | HCL (domain-specific) | Python, TypeScript, Go, C# |
| Learning curve | Low for ops teams | Low for developers |
| IDE support | Basic support | Full IntelliSense |
| Testing | External tools (Terratest) | Native unit tests |
| Debugging | Limited | Full |
| Community | Very large, established | Growing, smaller |
| Abstractions | Limited | Full |
| Loops/conditionals | Restricted | Full |
| Mature ecosystem | Yes | Building |
| Complexity | Low | High |
Developer experience:
| Aspect | Terraform | Pulumi |
|---|---|---|
| Syntax highlighting | Basic | Full |
| Auto-completion | Limited | Full |
| Refactoring | Manual | IDE-supported |
| Error messages | Good | Very good |
| Debugging tools | Limited | Full |
🔧 Practical example — complex logic:
import pulumi_aws as aws
# Dynamically create subnets for all AZs
azs = aws.get_availability_zones()
subnets = []
for i, az in enumerate(azs.names):
if i < 3: # Only the first 3 AZs
subnet = aws.ec2.Subnet(f"subnet-{i}",
vpc_id=vpc.id,
cidr_block=f"10.0.{i+1}.0/24",
availability_zone=az,
tags={
"Name": f"subnet-{az}",
"Tier": "public" if i % 2 == 0 else "private"
}
)
subnets.append(subnet)
# Conditional logic for environment
if pulumi.get_stack() == "production":
instance_type = "t3.large"
instance_count = 3
else:
instance_type = "t3.micro"
instance_count = 1
Terraform HCL:
data "aws_availability_zones" "available" {}
locals {
azs = slice(data.aws_availability_zones.available.names, 0, 3)
}
resource "aws_subnet" "main" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = local.azs[count.index]
tags = {
Name = "subnet-${local.azs[count.index]}"
Tier = count.index % 2 == 0 ? "public" : "private"
}
}
locals {
instance_config = {
production = {
type = "t3.large"
count = 3
}
staging = {
type = "t3.micro"
count = 1
}
}
}
Testing comparison:
| Feature | Terraform | Pulumi |
|---|---|---|
| Unit tests | Terratest (external) | Native support |
| Integration tests | Terratest | Native support |
| Mocking | Difficult | Easy |
| Test isolation | Complex | Easy |
| CI/CD integration | Good | Very good |
Pulumi unit test:
import unittest
import pulumi
class TestInfrastructure(unittest.TestCase):
@pulumi.runtime.test
def test_vpc_cidr(self):
def check_cidr(args):
vpc, = args
self.assertEqual(vpc.cidr_block, "10.0.0.0/16")
return pulumi.Output.all(vpc).apply(check_cidr)
Team adoption:
| Team profile | Terraform | Pulumi |
|---|---|---|
| Ops teams | Ideal | Learning curve |
| Developer teams | Learning curve | Ideal |
| Mixed teams | Good | Good |
| Python experience | Not needed | Advantageous |
| DevOps culture | Fits well | Fits perfectly |
Hybrid approaches: when you combine tools:
Why not only one tool? In reality many teams use a combination of tools, because each has its strengths.
| Combination | Terraform | Partner tool | Use case |
|---|---|---|---|
| Terraform + Ansible | Infrastructure | Configuration | Complete automation |
| Terraform + Helm | Cloud + K8s cluster | K8s applications | Kubernetes deployments |
| Terraform + CloudFormation | Multi-cloud | AWS-specific | Hybrid strategies |
| Terraform + Packer | Infrastructure | Images | Immutable infrastructure |
🔧 Practical example — Terraform + Ansible pipeline:
# Terraform creates the infrastructure
resource "aws_instance" "web" {
count = var.instance_count
ami = "ami-0c55b159cbfafe1d0"
instance_type = "t3.micro"
tags = {
Name = "web-${count.index}"
}
}
# Generate Ansible inventory
resource "local_file" "ansible_inventory" {
content = templatefile("inventory.tpl", {
web_servers = aws_instance.web[*].public_ip
})
filename = "ansible/inventory"
}
# Run Ansible playbook
resource "null_resource" "configure_servers" {
depends_on = [local_file.ansible_inventory]
provisioner "local-exec" {
command = "cd ansible && ansible-playbook -i inventory site.yml"
}
triggers = {
instance_ids = join(",", aws_instance.web[*].id)
}
}
Terraform’s unique features:
Why is Terraform often the best choice?
| Feature | Description | Advantage |
|---|---|---|
| Provider ecosystem | 3000+ providers | Everything from one place |
| Plan function | Preview before changes | Risk reduction |
| State management | Intelligent state handling | Efficient updates |
| Community | Large, active community | Support and modules |
| Vendor neutrality | Vendor-independent | Flexibility |
| Mature tooling | Proven CI/CD integration | Production-ready |
🔧 Practical example — plan function:
terraform plan
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c55b159cbfafe1d0"
+ instance_type = "t3.micro"
+ key_name = "my-key"
+ public_ip = (known after apply)
+ tags = {
+ "Name" = "web-server"
}
}
# aws_security_group.web will be modified
~ resource "aws_security_group" "web" {
id = "sg-12345678"
~ ingress {
+ from_port = 443
+ to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
Plan: 1 to add, 1 to change, 0 to destroy.
Concrete decision aids:
Decision matrix for tool choice:
| Criterion | Terraform | CloudFormation | Pulumi | Ansible |
|---|---|---|---|---|
| Multi-cloud | ✅ | ❌ | ✅ | ✅ |
| AWS integration | ✅ | ✅✅ | ✅ | ✅ |
| Simple syntax | ✅ | ⚠️ | ⚠️ | ✅ |
| Infrastructure focus | ✅✅ | ✅✅ | ✅✅ | ❌ |
| Config management | ❌ | ❌ | ❌ | ✅✅ |
| Developer-friendly | ✅ | ⚠️ | ✅✅ | ✅ |
| Ops-friendly | ✅✅ | ✅ | ⚠️ | ✅✅ |
| Testing support | ⚠️ | ⚠️ | ✅✅ | ✅ |
| Community | ✅✅ | ✅ | ✅ | ✅✅ |
| Enterprise support | 💰 | ✅ | 💰 | 💰 |
💡 Legend: ✅✅ = Excellent, ✅ = Good, ⚠️ = Acceptable, ❌ = Weak, 💰 = Paid
Concrete recommendations:
| Scenario | Recommendation | Reason |
|---|---|---|
| Startup, multi-cloud | Terraform | Flexibility, community |
| Enterprise, AWS-only | CloudFormation | Integration, support |
| Developer team | Pulumi | Familiar languages |
| Ops team | Terraform | Specialised, proven |
| Hybrid cloud | Terraform + Ansible | Best combination |
| Kubernetes-first | Terraform + Helm | Specialised tools |
💡 Takeaway: Terraform is not always the best choice, but often the best compromise. It is powerful enough for complex infrastructures, but simple enough for teams without deep programming knowledge. The combination of flexibility, community support and proven patterns makes it the safe choice for most infrastructure projects.
⚠️ Important note: Regardless of the tool, the most important decision is to start with Infrastructure as Code at all. Switching from manual to automated infrastructure management brings more benefit than choosing between IaC tools.
You now know the tool landscape and why Terraform is the right choice for most teams. Next: how Terraform actually works under the hood.
Terraform concepts and architecture
Core components
Terraform consists of four central components that work together to manage your infrastructure. Each component has a specific role, and understanding how they work together is decisive for successful use of Terraform. Each component is covered in detail below.
Providers and their role:
What are providers? Providers are plugins that connect Terraform to various APIs. They translate your HCL configuration into API calls to cloud providers, SaaS services or local systems. Without providers Terraform would only be a parser for configuration files.
Why are providers so important? Providers are the heart of Terraform’s flexibility. They let a single tool talk to thousands of different services. Each provider knows how to communicate with its specific service.
| Provider category | Examples | Resource types | Authentication |
|---|---|---|---|
| Cloud providers | AWS, Azure, GCP, DigitalOcean | Compute, storage, network | API keys, IAM roles |
| SaaS providers | GitHub, Datadog, PagerDuty | Repositories, dashboards | Token, OAuth |
| Database providers | MySQL, PostgreSQL, MongoDB | Users, databases, grants | Connection strings |
| Network providers | Cisco, F5, Cloudflare | Firewall rules, load balancers | Device credentials |
| Monitoring providers | Prometheus, Grafana, New Relic | Alerts, dashboards | API tokens |
| Utility providers | Local, HTTP, Random, Time | Files, HTTP calls, values | Local/none |
Provider authentication in detail:
AWS provider — authentication methods:
# Method 1: Direct configuration (not recommended for production)
provider "aws" {
region = "us-west-2"
access_key = "AKIA..."
secret_key = "..."
}
# Method 2: Environment variables
provider "aws" {
region = "us-west-2"
# AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from the environment
}
# Method 3: AWS profile
provider "aws" {
region = "us-west-2"
profile = "default"
}
# Method 4: IAM roles (recommended for EC2/ECS)
provider "aws" {
region = "us-west-2"
# Automatically from the instance metadata service
}
# Method 5: Assume role
provider "aws" {
region = "us-west-2"
assume_role {
role_arn = "arn:aws:iam::123456789012:role/TerraformRole"
}
}
Multi-provider configuration:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.0"
}
github = {
source = "integrations/github"
version = "~> 5.0"
}
}
}
# Multiple AWS regions
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
provider "aws" {
alias = "us_west_2"
region = "us-west-2"
}
# Cloudflare for DNS
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
# GitHub for repository management
provider "github" {
token = var.github_token
owner = var.github_organization
}
Provider performance and caching:
| Aspect | Details | Effect |
|---|---|---|
| API rate limits | AWS: 5000 req/sec, Azure: varies | Terraform waits automatically |
| Parallel requests | Default: 10 concurrent | Configurable via -parallelism |
| Caching | Provider-specific | Reduces API calls |
| Retry logic | Exponential backoff | Automatic retry |
| Timeout settings | Provider-configurable | Prevents hanging requests |
🔧 Practical example — provider optimisation:
provider "aws" {
region = "us-west-2"
# Performance optimisations
max_retries = 3
# Request timeouts
http_timeout = "30s"
# For large deployments
skip_metadata_api_check = true
skip_region_validation = true
# Default tags for every resource
default_tags {
tags = {
Environment = var.environment
Project = var.project_name
ManagedBy = "terraform"
CreatedAt = timestamp()
}
}
}
Provider versioning and upgrades:
| Version constraint | Meaning | Example |
|---|---|---|
= 5.0.0 |
Exact version | Only 5.0.0 |
>= 5.0.0 |
Minimum version | 5.0.0 or higher |
~> 5.0.0 |
Pessimistic operator | 5.0.x, but not 5.1.0 |
~> 5.0 |
Major version | 5.x.x, but not 6.0.0 |
>= 5.0, < 6.0 |
Range | Between 5.0 and 6.0 |
💡 Practical tip: Always use version constraints for providers.
~> 5.0is often the best compromise between stability and updates.
Common provider problems and solutions:
| Problem | Symptom | Solution |
|---|---|---|
| Authentication failed | Error: Authentication failed |
Check credentials |
| Rate limiting | Error: Rate limit exceeded |
Use -parallelism=5 |
| Version conflicts | Provider version constraint |
Adjust version constraints |
| Plugin download | Provider not found |
Run terraform init |
| Stale cache | Stale data | Delete the provider cache |
Resources — the heart of the infrastructure:
What are resources? Resources are the actual infrastructure objects Terraform manages. Each resource represents a specific object in your infrastructure — an EC2 instance, a database, a DNS record.
Why are resources the heart? Resources define the desired state of your infrastructure. Terraform compares that desired state with reality and runs the required changes.
Resource categories and their properties:
| Category | Examples | Lifecycle specifics | Dependencies |
|---|---|---|---|
| Compute | aws_instance, azurerm_virtual_machine | Restart on changes | VPC, security groups |
| Storage | aws_s3_bucket, google_storage_bucket | Versioning, lifecycle | IAM policies |
| Network | aws_vpc, aws_subnet, aws_security_group | Cascading deletes | Route tables, NAT |
| Database | aws_db_instance, azurerm_mysql_server | Backup before updates | Subnets, parameter groups |
| DNS | aws_route53_record, cloudflare_record | Propagation time | Hosted zones |
| IAM | aws_iam_role, aws_iam_policy | Permission boundaries | Trust relationships |
| Load balancer | aws_lb, azurerm_lb | Health checks | Target groups |
| Monitoring | aws_cloudwatch_alarm, datadog_monitor | Thresholds | Metrics, SNS topics |
Resource lifecycle in detail:
┌─────────────────────────────────────────────────────────────┐
│ Resource lifecycle (CRUD) │
├─────────────────────────────────────────────────────────────┤
│ │
│ CREATE READ UPDATE DELETE │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ apply refresh apply destroy │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Special operations: │ │
│ │ • import: Take existing resources into state │ │
│ │ • taint: Mark a resource for recreation │ │
│ │ • untaint: Remove the taint mark │ │
│ │ • replace: Targeted resource replacement │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
🔧 Practical example — complex resource configuration:
# EC2 instance with extended properties
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
key_name = aws_key_pair.deployer.key_name
# Network configuration
vpc_security_group_ids = [
aws_security_group.web.id,
aws_security_group.ssh.id
]
subnet_id = aws_subnet.public.id
associate_public_ip_address = true
# Storage configuration
root_block_device {
volume_type = "gp3"
volume_size = 20
encrypted = true
delete_on_termination = true
tags = {
Name = "root-volume"
}
}
# Additional EBS volumes
ebs_block_device {
device_name = "/dev/sdb"
volume_type = "gp3"
volume_size = 100
encrypted = true
delete_on_termination = false
tags = {
Name = "data-volume"
}
}
# Monitoring
monitoring = true
# Placement
availability_zone = data.aws_availability_zones.available.names[0]
# User data script
user_data = base64encode(templatefile("${path.module}/userdata.sh", {
db_host = aws_db_instance.main.endpoint
app_env = var.environment
}))
# Lifecycle rules
lifecycle {
create_before_destroy = true
ignore_changes = [
ami, # Ignore AMI updates
user_data, # Ignore user data changes
]
}
# Detailed tags
tags = {
Name = "${var.project}-web-${var.environment}"
Environment = var.environment
Project = var.project
Role = "webserver"
Backup = "daily"
Monitoring = "enabled"
}
}
# Security group with detailed rules
resource "aws_security_group" "web" {
name_prefix = "${var.project}-web-"
description = "Security group for web servers"
vpc_id = aws_vpc.main.id
# HTTP from anywhere
ingress {
description = "HTTP from internet"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# HTTPS from anywhere
ingress {
description = "HTTPS from internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# SSH only from the management subnet
ingress {
description = "SSH from management"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [aws_subnet.management.cidr_block]
}
# Application port from load balancer
ingress {
description = "App port from ALB"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
# Outbound traffic
egress {
description = "All outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
# Lifecycle rules
lifecycle {
create_before_destroy = true
}
tags = {
Name = "${var.project}-web-sg"
}
}
Resource dependencies and the dependency graph:
┌─────────────────────────────────────────────────────────────┐
│ Dependency graph (dependency resolution) │
├─────────────────────────────────────────────────────────────┤
│ │
│ aws_vpc (base network) │
│ │ │
│ ├──► aws_internet_gateway │
│ │ │
│ ├──► aws_subnet (public and private) ───┐ │
│ │ │ │ │
│ │ ├──► aws_route_table │ │
│ │ └──► aws_nat_gateway │ │
│ │ ▼ │
│ └──► aws_security_group ────► aws_instance (EC2) │
│ │ │
│ ▼ │
│ aws_lb (load balancer) │
│ │
└─────────────────────────────────────────────────────────────┘
Implicit vs. explicit dependencies:
| Type | Example | Terraform behaviour |
|---|---|---|
| Implicit | subnet_id = aws_subnet.main.id |
Detected automatically |
| Explicit | depends_on = [aws_iam_role.app] |
Defined manually |
| Circular | A → B → A | Error message |
| Parallel | Independent resources | Created in parallel |
Resource meta-arguments:
| Meta-argument | Purpose | Example |
|---|---|---|
depends_on |
Explicit dependencies | depends_on = [aws_iam_role.app] |
count |
Multiple instances | count = 3 |
for_each |
Map-based instances | for_each = var.instances |
provider |
Provider selection | provider = aws.us_west_2 |
lifecycle |
Lifecycle rules | create_before_destroy = true |
🔧 Practical example - Count and For_Each:
# Count-based resources
resource "aws_instance" "web" {
count = var.instance_count
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "web-${count.index + 1}"
}
}
# For_Each-based resources (more flexible)
resource "aws_instance" "app" {
for_each = var.applications
ami = data.aws_ami.ubuntu.id
instance_type = each.value.instance_type
tags = {
Name = "app-${each.key}"
Application = each.key
Environment = each.value.environment
}
}
# Variable for For_Each
variable "applications" {
type = map(object({
instance_type = string
environment = string
}))
default = {
frontend = {
instance_type = "t3.micro"
environment = "production"
}
backend = {
instance_type = "t3.small"
environment = "production"
}
worker = {
instance_type = "t3.medium"
environment = "production"
}
}
}
Lifecycle management:
| Lifecycle rule | Purpose | Use case |
|---|---|---|
create_before_destroy |
New resource before deletion | Zero-downtime updates |
prevent_destroy |
Block deletion | Production databases |
ignore_changes |
Ignore changes | External modifications |
replace_triggered_by |
Trigger replacement | Dependent updates |
Common resource problems and solutions:
| Problem | Symptom | Solution |
|---|---|---|
| Circular Dependency | Cycle: resource.a → resource.b → resource.a |
Restructure dependencies |
| Resource Drift | State vs. reality differ | terraform refresh |
| Timeout Errors | Error: timeout while waiting |
Raise timeout values |
| Permission Denied | Error: UnauthorizedOperation |
Check IAM permissions |
| Resource Already Exists | Error: already exists |
Use terraform import |
Data sources - pulling in external information:
What are data sources? Data sources let you query information from existing infrastructure without managing it. They are read-only and exist to feed external data into your Terraform configuration.
Why do you need data sources? Not everything in your infrastructure is managed by Terraform. Data sources let you reference existing resources or query dynamic information.
Data source categories:
| Category | Examples | Use case | Update frequency |
|---|---|---|---|
| Existing Resources | aws_vpc, aws_subnet | Reference to legacy infrastructure | On every plan |
| Dynamic Info | aws_availability_zones, aws_ami | Current information | On every plan |
| External APIs | http, external | External services | On every plan |
| Account Info | aws_caller_identity, aws_region | Account-specific data | Cached |
| Computed Values | aws_route53_zone, aws_acm_certificate | Computed values | On every plan |
🔧 Practical example - advanced data sources:
# Existing VPC with complex filters
data "aws_vpc" "existing" {
filter {
name = "tag:Environment"
values = ["production"]
}
filter {
name = "tag:Team"
values = ["platform"]
}
filter {
name = "state"
values = ["available"]
}
}
# Available availability zones with filters
data "aws_availability_zones" "available" {
state = "available"
filter {
name = "zone-type"
values = ["availability-zone"]
}
exclude_names = ["us-west-2d"] # Exclude a problematic AZ
}
# Latest AMI with complex criteria
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "state"
values = ["available"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
}
# Subnets with dynamic selection
data "aws_subnets" "private" {
filter {
name = "vpc-id"
values = [data.aws_vpc.existing.id]
}
filter {
name = "tag:Type"
values = ["private"]
}
filter {
name = "availability-zone"
values = data.aws_availability_zones.available.names
}
}
# Security groups with complex filters
data "aws_security_groups" "web" {
filter {
name = "vpc-id"
values = [data.aws_vpc.existing.id]
}
filter {
name = "tag:Purpose"
values = ["web", "frontend"]
}
filter {
name = "group-name"
values = ["*-web-*"]
}
}
# External API calls
data "http" "my_public_ip" {
url = "https://ifconfig.me/ip"
request_headers = {
Accept = "text/plain"
}
}
# Run external scripts
data "external" "vault_token" {
program = ["bash", "${path.module}/scripts/get_vault_token.sh"]
query = {
vault_addr = var.vault_addr
role_id = var.vault_role_id
}
}
# SSL certificate information
data "aws_acm_certificate" "main" {
domain = "*.${var.domain_name}"
statuses = ["ISSUED"]
most_recent = true
}
# Route53 hosted zone
data "aws_route53_zone" "main" {
name = var.domain_name
private_zone = false
}
Data source performance and caching:
| Aspect | Behaviour | Optimisation |
|---|---|---|
| Caching | Inside terraform plan | Use local variables |
| API calls | On every plan/apply | Use filters |
| Parallelisation | In parallel with resources | Minimise dependencies |
| Error handling | Retry mechanisms | Adjust timeout values |
Data sources vs. resources - detailed comparison:
| Aspect | Data sources | Resources |
|---|---|---|
| Purpose | Query information | Manage infrastructure |
| Access | Read-only | Read/Write |
| Lifecycle | No management | Create/Update/Delete |
| State | Not persistent | Stored persistently |
| Syntax | data "type" "name" |
resource "type" "name" |
| Dependencies | Can be referenced | Can use data sources |
| Performance | Every plan queries | Only on changes |
| Error handling | Error stops the plan | Rollback possible |
🔧 Practical example - data sources in action:
# Local values for better performance
locals {
vpc_id = data.aws_vpc.existing.id
subnet_ids = data.aws_subnets.private.ids
# Computed values
az_count = length(data.aws_availability_zones.available.names)
# Conditional logic
use_existing_vpc = var.vpc_id != "" ? var.vpc_id : data.aws_vpc.existing.id
}
# Resources with data source references
resource "aws_instance" "web" {
count = local.az_count
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = local.subnet_ids[count.index]
vpc_security_group_ids = data.aws_security_groups.web.ids
tags = {
Name = "web-${count.index + 1}"
AZ = data.aws_availability_zones.available.names[count.index]
}
}
# Load balancer with data source configuration
resource "aws_lb" "main" {
name = "main-alb"
internal = false
load_balancer_type = "application"
subnets = data.aws_subnets.private.ids
security_groups = data.aws_security_groups.web.ids
tags = {
VPC = data.aws_vpc.existing.tags.Name
}
}
# Route53 record with external data
resource "aws_route53_record" "api" {
zone_id = data.aws_route53_zone.main.zone_id
name = "api.${data.aws_route53_zone.main.name}"
type = "A"
alias {
name = aws_lb.main.dns_name
zone_id = aws_lb.main.zone_id
evaluate_target_health = true
}
}
💡 Practical tips for data sources:
- Use local variables for frequently referenced data sources
- Apply specific filters to reduce API calls
- Remember that data sources are queried on every plan
- Use
depends_ononly when needed, because it blocks parallelisation
Common data source problems:
| Problem | Symptom | Solution |
|---|---|---|
| No Results | Error: no matching resources found |
Check filters |
| Multiple Results | Error: multiple resources found |
Use more specific filters |
| Permission Denied | Error: AccessDenied |
Check IAM permissions |
| Timeout | Error: timeout while reading |
Check network/provider |
| Stale Data | Outdated information | terraform refresh |
Modules - reusable infrastructure components:
What are modules? Modules are reusable Terraform configurations. They group related resources into logical units and let you encapsulate and share proven patterns.
Why do modules matter? Modules reduce code duplication, raise consistency, and make complex infrastructure easier to maintain. They are the equivalent of functions in programming languages.
Module hierarchy and types:
| Module type | Description | Example | Versioning |
|---|---|---|---|
| Root Module | Main configuration | Your main.tf | Git tags |
| Child Module | Reusable components | VPC, EKS cluster | Semantic versioning |
| Local Module | Project-specific modules | ./modules/webapp |
Project versioning |
| Remote Module | Public modules | Terraform Registry | Registry versioning |
| Private Module | Company modules | Private registry | Internal versioning |
Module architecture:
┌─────────────────────────────────────────────────────────────┐
│ MODULE ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ Root Module (main.tf, variables.tf, outputs.tf) │
│ │ │
│ ├──► Local Module: ./modules/vpc │
│ │ ├── main.tf, variables.tf │
│ │ └── outputs.tf, versions.tf │
│ │ │
│ ├──► Public Registry: terraform-aws-modules/eks/aws │
│ │ └── Version constraint: ~> 19.0 │
│ │ │
│ └──► Private Registry: app.terraform.io/company/sec │
│ └── Version: 1.2.3 │
│ │
└─────────────────────────────────────────────────────────────┘
🔧 Practical example - complex VPC module:
Module structure:
┌─────────────────────────────────────────────────────────────┐
│ DIRECTORY STRUCTURE: MODULES/VPC │
├─────────────────────────────────────────────────────────────┤
│ │
│ modules/vpc/ │
│ ├── main.tf # Resource definitions │
│ ├── variables.tf # Module input variables │
│ ├── outputs.tf # Exposed return values │
│ ├── versions.tf # Provider and version constraints │
│ ├── locals.tf # Local variables and helpers │
│ └── README.md # Technical module documentation │
│ │
└─────────────────────────────────────────────────────────────┘
variables.tf:
variable "name" {
description = "Name for the VPC and related resources"
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 32
error_message = "VPC name must be between 1 and 32 characters."
}
}
variable "cidr_block" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "CIDR block must be a valid IPv4 CIDR."
}
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
validation {
condition = length(var.availability_zones) >= 2
error_message = "At least 2 availability zones are required."
}
}
variable "public_subnets" {
description = "List of public subnet CIDR blocks"
type = list(string)
default = []
}
variable "private_subnets" {
description = "List of private subnet CIDR blocks"
type = list(string)
default = []
}
variable "enable_nat_gateway" {
description = "Enable NAT gateway for private subnets"
type = bool
default = true
}
variable "single_nat_gateway" {
description = "Use single NAT gateway for all private subnets"
type = bool
default = false
}
variable "enable_dns_hostnames" {
description = "Enable DNS hostnames in the VPC"
type = bool
default = true
}
variable "enable_dns_support" {
description = "Enable DNS support in the VPC"
type = bool
default = true
}
variable "tags" {
description = "Additional tags for all resources"
type = map(string)
default = {}
}
locals.tf:
locals {
# Compute number of AZs
az_count = length(var.availability_zones)
# NAT gateway count
nat_gateway_count = var.single_nat_gateway ? 1 : local.az_count
# Shared tags
common_tags = merge(
var.tags,
{
ManagedBy = "terraform"
Module = "vpc"
}
)
# Subnet calculations
public_subnet_count = length(var.public_subnets)
private_subnet_count = length(var.private_subnets)
# Validation
has_public_subnets = local.public_subnet_count > 0
has_private_subnets = local.private_subnet_count > 0
# Route table assignments
private_route_table_ids = var.single_nat_gateway ? [aws_route_table.private[0].id] : aws_route_table.private[*].id
}
main.tf:
# Create VPC
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_hostnames = var.enable_dns_hostnames
enable_dns_support = var.enable_dns_support
tags = merge(
local.common_tags,
{
Name = var.name
}
)
}
# Internet gateway
resource "aws_internet_gateway" "main" {
count = local.has_public_subnets ? 1 : 0
vpc_id = aws_vpc.main.id
tags = merge(
local.common_tags,
{
Name = "${var.name}-igw"
}
)
}
# Public subnets
resource "aws_subnet" "public" {
count = local.public_subnet_count
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnets[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = merge(
local.common_tags,
{
Name = "${var.name}-public-${count.index + 1}"
Type = "public"
}
)
}
# Private subnets
resource "aws_subnet" "private" {
count = local.private_subnet_count
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnets[count.index]
availability_zone = var.availability_zones[count.index]
tags = merge(
local.common_tags,
{
Name = "${var.name}-private-${count.index + 1}"
Type = "private"
}
)
}
# Elastic IPs for NAT gateways
resource "aws_eip" "nat" {
count = local.has_private_subnets && var.enable_nat_gateway ? local.nat_gateway_count : 0
domain = "vpc"
depends_on = [aws_internet_gateway.main]
tags = merge(
local.common_tags,
{
Name = "${var.name}-nat-eip-${count.index + 1}"
}
)
}
# NAT gateways
resource "aws_nat_gateway" "main" {
count = local.has_private_subnets && var.enable_nat_gateway ? local.nat_gateway_count : 0
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
depends_on = [aws_internet_gateway.main]
tags = merge(
local.common_tags,
{
Name = "${var.name}-nat-${count.index + 1}"
}
)
}
# Public route table
resource "aws_route_table" "public" {
count = local.has_public_subnets ? 1 : 0
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main[0].id
}
tags = merge(
local.common_tags,
{
Name = "${var.name}-public-rt"
}
)
}
# Private route tables
resource "aws_route_table" "private" {
count = local.has_private_subnets ? local.nat_gateway_count : 0
vpc_id = aws_vpc.main.id
dynamic "route" {
for_each = var.enable_nat_gateway ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
}
tags = merge(
local.common_tags,
{
Name = "${var.name}-private-rt-${count.index + 1}"
}
)
}
# Public route table associations
resource "aws_route_table_association" "public" {
count = local.public_subnet_count
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public[0].id
}
# Private route table associations
resource "aws_route_table_association" "private" {
count = local.private_subnet_count
subnet_id = aws_subnet.private[count.index].id
route_table_id = local.private_route_table_ids[var.single_nat_gateway ? 0 : count.index]
}
outputs.tf:
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "vpc_cidr_block" {
description = "CIDR block of the VPC"
value = aws_vpc.main.cidr_block
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of the private subnets"
value = aws_subnet.private[*].id
}
output "internet_gateway_id" {
description = "ID of the internet gateway"
value = local.has_public_subnets ? aws_internet_gateway.main[0].id : null
}
output "nat_gateway_ids" {
description = "IDs of the NAT gateways"
value = aws_nat_gateway.main[*].id
}
output "public_route_table_id" {
description = "ID of the public route table"
value = local.has_public_subnets ? aws_route_table.public[0].id : null
}
output "private_route_table_ids" {
description = "IDs of the private route tables"
value = aws_route_table.private[*].id
}
output "availability_zones" {
description = "List of availability zones used"
value = var.availability_zones
}
Module usage:
# Development environment
module "vpc_dev" {
source = "./modules/vpc"
name = "dev-vpc"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-west-2a", "us-west-2b"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.10.0/24", "10.0.20.0/24"]
enable_nat_gateway = true
single_nat_gateway = true # Cost saving
tags = {
Environment = "development"
Project = "my-app"
}
}
# Production environment
module "vpc_prod" {
source = "./modules/vpc"
name = "prod-vpc"
cidr_block = "10.1.0.0/16"
availability_zones = ["us-west-2a", "us-west-2b", "us-west-2c"]
public_subnets = ["10.1.1.0/24", "10.1.2.0/24", "10.1.3.0/24"]
private_subnets = ["10.1.10.0/24", "10.1.20.0/24", "10.1.30.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # High availability
tags = {
Environment = "production"
Project = "my-app"
}
}
# Remote module from the registry
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "19.0.0"
cluster_name = "my-cluster"
cluster_version = "1.24"
vpc_id = module.vpc_prod.vpc_id
subnet_ids = module.vpc_prod.private_subnet_ids
eks_managed_node_groups = {
main = {
instance_types = ["t3.medium"]
min_size = 1
max_size = 3
desired_size = 2
}
}
tags = {
Environment = "production"
}
}
Module versioning and lifecycle:
| Strategy | Description | Example |
|---|---|---|
| Semantic Versioning | MAJOR.MINOR.PATCH | 1.2.3 |
| Git Tags | Tag-based versioning | git tag v1.0.0 |
| Branch-based | Feature branches | ref=feature/new-feature |
| Registry Versioning | Terraform Registry | version = "~> 1.0" |
Module testing strategies:
| Test type | Tool | Purpose |
|---|---|---|
| Unit Tests | Terratest | Test individual modules |
| Integration Tests | Kitchen-Terraform | Module interplay |
| Compliance Tests | Checkov, tfsec | Security validation |
| Performance Tests | Custom scripts | Resource consumption |
💡 Module best practices:
- Use semantic versioning for public modules
- Implement input validation for critical parameters
- Document all input and output variables
- Use local modules for project-specific patterns
- Test modules in isolated environments
Common module problems:
| Problem | Symptom | Solution |
|---|---|---|
| Version Conflicts | Module version constraint |
Adjust version constraints |
| Circular Dependencies | Module cycle detected |
Rethink module architecture |
| State Isolation | Unexpected changes | Separate state files |
| Variable Passing | variable not declared |
Check variable definitions |
| Output References | output not found |
Check output definitions |
How all components work together:
┌─────────────────────────────────────────────────────────────┐
│ COMPONENT INTERACTION FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Provider ◄── Authentication ───► Cloud APIs │
│ │ │
│ ▼ │
│ 2. Data Sources ◄── Queries ──────────► External infra │
│ │ │
│ ▼ │
│ 3. Resources ◄── CRUD operations ──► Target infra │
│ │ │
│ ▼ │
│ 4. Modules ◄── Encapsulation ────► Building blocks │
│ │ │
│ ▼ │
│ 5. State File ◄── State tracking ───► tfstate │
│ │
└─────────────────────────────────────────────────────────────┘
Those four core Terraform components each have a specific role; together they form a system for managing infrastructure. The next section shows how they cooperate in the typical Terraform workflow.
Terraform Workflow
Terraform follows a clear, repeatable process: Write → Plan → Apply → Destroy. That workflow is the core of day-to-day work with the tool.
Write → Plan → Apply → Destroy:
What is the Terraform workflow? A four-stage process that takes you from configuration through provisioning and management of your infrastructure. Each step has a specific job and builds on the previous one.
Why does this workflow matter? The structured approach prevents mistakes, enables reviews, and keeps you in control of infrastructure changes. You always see what will happen before it happens.
The complete workflow in detail:
┌─────────────────────────────────────────────────────────────┐
│ THE 5-PHASE TERRAFORM WORKFLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. WRITE: Create .tf files and define variables │
│ │ │
│ ▼ │
│ 2. INIT: Load providers, fetch modules, bind backend │
│ │ │
│ ▼ │
│ 3. PLAN: Compute diff, build the dependency graph │
│ │ │
│ ▼ │
│ 4. APPLY: Call cloud APIs and update state │
│ │ │
│ ▼ │
│ 5. DESTROY: Clean up resources that are no longer needed │
│ │
└─────────────────────────────────────────────────────────────┘
Workflow phases in detail:
| Phase | Purpose | Main activities | Duration |
|---|---|---|---|
| Write | Create configuration | Write HCL, define variables | Minutes to hours |
| Init | Prepare the working environment | Load providers, configure backend | 10-60 seconds |
| Plan | Preview changes | Compute diff, analyse dependencies | 10-300 seconds |
| Apply | Execute changes | API calls, create resources | Minutes to hours |
| Destroy | Clean up | Delete resources, clean state | Minutes to hours |
Write phase: create the configuration:
What happens in the Write phase? You create and edit your Terraform configuration files. That includes writing HCL, defining variables, and structuring your infrastructure.
Why is this phase so important? This is where you lay the foundation for the entire infrastructure. Clean planning and clean code here save a lot of time and trouble later.
🔧 Practical example - typical Write phase:
Build the project structure:
┌─────────────────────────────────────────────────────────────┐
│ PROJECT DIRECTORY LAYOUT │
├─────────────────────────────────────────────────────────────┤
│ │
│ project/ │
│ ├── main.tf # Central resource logic │
│ ├── variables.tf # Variable declarations │
│ ├── outputs.tf # Return values │
│ ├── versions.tf # Provider and Terraform versions │
│ ├── terraform.tfvars # Default variable values │
│ ├── modules/ # Module collection │
│ │ └── vpc/ # Local VPC module │
│ │ ├── main.tf, variables.tf, outputs.tf │
│ └── environments/ # Environment configurations │
│ ├── dev/terraform.tfvars │
│ └── prod/terraform.tfvars │
│ │
└─────────────────────────────────────────────────────────────┘
versions.tf - provider requirements:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.1"
}
}
# Backend configuration
backend "s3" {
bucket = "my-terraform-state"
key = "infrastructure/terraform.tfstate"
region = "us-west-2"
}
}
variables.tf - input variables:
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "project_name" {
description = "Name of the project"
type = string
validation {
condition = can(regex("^[a-zA-Z0-9-]+$", var.project_name))
error_message = "Project name must contain only alphanumeric characters and hyphens."
}
}
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "VPC CIDR must be a valid IPv4 CIDR block."
}
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
default = ["us-west-2a", "us-west-2b"]
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "enable_monitoring" {
description = "Enable CloudWatch monitoring"
type = bool
default = true
}
variable "tags" {
description = "Common tags for all resources"
type = map(string)
default = {}
}
main.tf - main configuration:
# Local calculations
locals {
common_tags = merge(
var.tags,
{
Environment = var.environment
Project = var.project_name
ManagedBy = "terraform"
CreatedAt = timestamp()
}
)
# Computed values
vpc_name = "${var.project_name}-${var.environment}-vpc"
# Subnet calculation
public_subnets = [for i, az in var.availability_zones : cidrsubnet(var.vpc_cidr, 8, i)]
private_subnets = [for i, az in var.availability_zones : cidrsubnet(var.vpc_cidr, 8, i + 10)]
}
# VPC module
module "vpc" {
source = "./modules/vpc"
name = local.vpc_name
cidr_block = var.vpc_cidr
availability_zones = var.availability_zones
public_subnets = local.public_subnets
private_subnets = local.private_subnets
enable_nat_gateway = var.environment == "prod" ? true : false
single_nat_gateway = var.environment != "prod" ? true : false
tags = local.common_tags
}
# Security group for web servers
resource "aws_security_group" "web" {
name_prefix = "${var.project_name}-${var.environment}-web-"
description = "Security group for web servers"
vpc_id = module.vpc.vpc_id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(
local.common_tags,
{
Name = "${var.project_name}-${var.environment}-web-sg"
}
)
lifecycle {
create_before_destroy = true
}
}
# Launch template for Auto Scaling
resource "aws_launch_template" "web" {
name_prefix = "${var.project_name}-${var.environment}-web-"
image_id = data.aws_ami.ubuntu.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
user_data = base64encode(templatefile("${path.module}/userdata.sh", {
project_name = var.project_name
environment = var.environment
}))
monitoring {
enabled = var.enable_monitoring
}
tag_specifications {
resource_type = "instance"
tags = merge(
local.common_tags,
{
Name = "${var.project_name}-${var.environment}-web"
}
)
}
lifecycle {
create_before_destroy = true
}
}
Write-phase best practices:
| Aspect | Best practice | Reason |
|---|---|---|
| File structure | Logical split | Better maintainability |
| Naming | Consistent conventions | Easy navigation |
| Variables | Use validation | Catch errors early |
| Comments | Explain complex logic | Understandability |
| Locals | Encapsulate calculations | Reusability |
Init phase: prepare the working environment:
What happens during terraform init? Terraform initialises the working directory, downloads providers, configures the backend, and prepares modules. That is the first step after writing the configuration.
Why is Init so important? Terraform cannot work without it. Init makes sure all dependencies are available and the backend is configured correctly.
Init process in detail:
┌─────────────────────────────────────────────────────────────┐
│ TERRAFORM INIT PHASE FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Backend configuration: │
│ • Check state location and access rights │
│ • S3 bucket, DynamoDB locking, or local state │
│ │
│ 2. Provider installation: │
│ • Evaluate versions.tf and download binaries │
│ • Store in .terraform/providers/ │
│ │
│ 3. Module processing: │
│ • Clone remote modules and link local paths │
│ │
│ 4. Lock file generation: │
│ • Compute checksums and write .terraform.lock.hcl │
│ │
└─────────────────────────────────────────────────────────────┘
🔧 Practical example - Init process:
First init:
terraform init
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Finding hashicorp/random versions matching "~> 3.1"...
- Installing hashicorp/aws v5.31.0...
- Installing hashicorp/random v3.4.3...
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Initializing modules...
- vpc in modules/vpc
Terraform has been successfully initialized!
Init options and when to use them:
| Option | Purpose | Use case |
|---|---|---|
-upgrade |
Provider updates | New provider versions |
-reconfigure |
Reconfigure backend | Backend switch |
-migrate-state |
Migrate state | Backend migration |
-get=false |
Do not load modules | Troubleshooting |
-backend=false |
Do not configure backend | Local tests |
Backend configuration:
# S3 backend with DynamoDB locking
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "infrastructure/terraform.tfstate"
region = "us-west-2"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Lock file (.terraform.lock.hcl):
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "5.31.0"
constraints = "~> 5.0"
hashes = [
"h1:ltxyuBWIy9cq0k9gMCcE7c7wgmPHJaZGlb5lOEjqITE=",
"zh:0cdb9c2083681ddf2c1f1b0e1e2e2e0b4e5e8f7a7b0a1b2c3d4e5f6789abcdef...",
]
}
💡 Practical tips for Init:
- Run
terraform initafter every change to providers or modules- Commit
.terraform.lock.hclto Git for reproducible builds- Use
-upgradeonly deliberately so you stay in control of provider updates
Plan phase: preview changes:
What happens during terraform plan? Terraform analyses your configuration, compares it with the current state, and shows which changes would be made. Think of it as a diff for your infrastructure.
Why is Plan so valuable? Plan is your safety layer. You see exactly what will happen before it happens. That prevents nasty surprises and enables code reviews.
Plan process in detail:
┌─────────────────────────────────────────────────────────────┐
│ TERRAFORM PLAN PHASE FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Parsing: Read HCL and resolve variables │
│ 2. State-Check: Query current actual state via API │
│ 3. Graph-Build: Determine dependencies and parallelism │
│ 4. Diff-Compute: Determine actions (+ add, ~ mod, - del) │
│ 5. Output: Display the execution plan (preview) │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Formula: Diff = desired state - actual state (API) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
🔧 Practical example - Plan output:
terraform plan
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
~ update in-place
- destroy
-/+ destroy and then create replacement
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c55b159cbfafe1d0"
+ instance_type = "t3.micro"
+ tags = {
+ "Environment" = "dev"
+ "Name" = "web-server"
+ "Project" = "my-app"
}
}
# aws_security_group.web will be updated in-place
~ resource "aws_security_group" "web" {
id = "sg-12345678"
~ ingress {
~ cidr_blocks = [
- "10.0.0.0/16",
+ "0.0.0.0/0",
]
from_port = 443
protocol = "tcp"
to_port = 443
}
}
# aws_instance.old will be destroyed
- resource "aws_instance" "old" {
- ami = "ami-0abcdef1234567890" -> null
- instance_type = "t2.micro" -> null
- id = "i-1234567890abcdef0" -> null
}
Plan: 1 to add, 1 to change, 1 to destroy.
Plan symbols and what they mean:
| Symbol | Meaning | Description |
|---|---|---|
+ |
Create | New resource will be created |
~ |
Update | Resource will be changed in place |
- |
Delete | Resource will be deleted |
-/+ |
Replace | Resource will be deleted and recreated |
<= |
Read | Data source will be read |
# |
Comment | Comment or explanation |
Plan options:
| Option | Purpose | Example |
|---|---|---|
-out=FILE |
Save plan to a file | terraform plan -out=tfplan |
-target=RESOURCE |
Only a specific resource | terraform plan -target=aws_instance.web |
-var="key=value" |
Override a variable | terraform plan -var="instance_type=t3.small" |
-var-file=FILE |
Use a variable file | terraform plan -var-file=prod.tfvars |
-refresh=false |
Skip state refresh | terraform plan -refresh=false |
-detailed-exitcode |
Detailed exit code | For CI/CD pipelines |
Plan analysis:
# Plan with details
terraform plan -detailed-exitcode
# Exit codes:
# 0 = No changes
# 1 = Error
# 2 = Changes present
# Save plan to a file
terraform plan -out=tfplan
# Show a saved plan
terraform show tfplan
# Plan as JSON
terraform show -json tfplan | jq .
💡 Plan best practices:
- Always run
terraform planbeforeterraform apply- Save important plans with
-outfor later use- Use
-targetonly for debugging, not for normal workflows- Always check the change count in the summary
Common plan problems:
| Problem | Symptom | Solution |
|---|---|---|
| State Drift | Unexpected changes | terraform refresh |
| Missing Resources | Resources not found | State import or recreation |
| Permission Errors | AccessDenied |
Check IAM permissions |
| Version Conflicts | Provider conflicts | Adjust provider versions |
| Circular Dependencies | Dependency errors | Rethink resource design |
Apply phase: execute changes:
What happens during terraform apply? Terraform executes the changes computed in the Plan phase. It creates, updates, or deletes resources and updates state accordingly.
Why is Apply the most critical step? Real changes are made to your infrastructure here. A mistake can cause downtime or data loss.
Apply process in detail:
┌─────────────────────────────────────────────────────────────┐
│ TERRAFORM APPLY PHASE FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Validation: Load plan or confirm interactively │
│ 2. Graph-Walk: Parallelised execution (10 tasks) │
│ 3. API-Execution: Create, change, or delete resources │
│ 4. State-Update: Persist state atomically │
│ 5. Outputs: Print return values to the console │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Safety: state lock prevents parallel access │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
🔧 Practical example - Apply process:
terraform apply
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c55b159cbfafe1d0"
+ instance_type = "t3.micro"
}
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Still creating... [20s elapsed]
aws_instance.web: Creation complete after 23s [id=i-0123456789abcdef0]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
instance_id = "i-0123456789abcdef0"
instance_ip = "54.123.45.67"
Apply with a saved plan:
# Create and save a plan
terraform plan -out=tfplan
# Apply the saved plan (no confirmation)
terraform apply tfplan
aws_instance.web: Creating...
aws_instance.web: Creation complete after 23s [id=i-0123456789abcdef0]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Apply options:
| Option | Purpose | Use case |
|---|---|---|
-auto-approve |
Automatic confirmation | CI/CD pipelines |
-target=RESOURCE |
Only a specific resource | Debugging |
-parallelism=N |
Limit parallelism | Rate limiting |
-refresh=false |
Skip state refresh | Performance |
-replace=RESOURCE |
Replace a resource | Troubleshooting |
Apply with different strategies:
# Automatic confirmation (for CI/CD)
terraform apply -auto-approve
# Reduce parallelism (for rate limits)
terraform apply -parallelism=3
# Replace a specific resource
terraform apply -replace=aws_instance.web
# With a variables file
terraform apply -var-file=production.tfvars
# Target-specific apply
terraform apply -target=module.vpc
Apply monitoring and logging:
# Detailed logs
TF_LOG=DEBUG terraform apply
# Logs to a file
TF_LOG=INFO TF_LOG_PATH=./terraform.log terraform apply
# JSON output for parsing
terraform apply -json | jq .
Error handling during Apply:
| Error type | Behaviour | Recovery |
|---|---|---|
| API error | Retry with backoff | Automatic |
| Timeout | Operation aborts | Retry manually |
| Dependency error | Rollback | Adjust the plan |
| Permission error | Immediate stop | Check permissions |
| Resource conflict | Error message | Resolve the conflict |
💡 Apply best practices:
- Use saved plans for important deployments
- Reduce parallelism when you hit rate-limiting issues
- Watch logs on complex deployments
- Run Apply in controlled environments
Destroy phase: clean up resources:
What happens during terraform destroy? Terraform deletes all resources defined in the configuration, in reverse dependency order.
Why does Destroy matter? Destroy lets you tear down temporary environments, save cost, and reset test environments.
🔧 Practical example - Destroy process:
terraform destroy
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
- destroy
Terraform will perform the following actions:
# aws_instance.web will be destroyed
- resource "aws_instance" "web" {
- ami = "ami-0c55b159cbfafe1d0" -> null
- instance_type = "t3.micro" -> null
- id = "i-0123456789abcdef0" -> null
}
# aws_security_group.web will be destroyed
- resource "aws_security_group" "web" {
- id = "sg-12345678" -> null
- name = "web-sg" -> null
}
Plan: 0 to add, 0 to change, 2 to destroy.
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes
aws_instance.web: Destroying... [id=i-0123456789abcdef0]
aws_instance.web: Still destroying... [id=i-0123456789abcdef0, 10s elapsed]
aws_instance.web: Destruction complete after 23s
aws_security_group.web: Destroying... [id=sg-12345678]
aws_security_group.web: Destruction complete after 1s
Destroy complete! Resources: 2 destroyed.
Destroy options:
| Option | Purpose | Example |
|---|---|---|
-target=RESOURCE |
Only a specific resource | terraform destroy -target=aws_instance.web |
-auto-approve |
Automatic confirmation | terraform destroy -auto-approve |
-parallelism=N |
Control parallelism | terraform destroy -parallelism=1 |
Selective Destroy:
# Only a specific resource
terraform destroy -target=aws_instance.web
# Multiple resources
terraform destroy -target=aws_instance.web -target=aws_security_group.web
# With automatic confirmation
terraform destroy -auto-approve
⚠️ Destroy safety:
| Protection mechanism | Purpose | Configuration |
|---|---|---|
| prevent_destroy | Accidental deletion | prevent_destroy = true |
| Confirmation | Deliberate decision | Type "yes" manually |
| Backup | State backup | Back up state before destroy |
| Staging | Test environment | Destroy in test first |
Lifecycle protection:
resource "aws_db_instance" "main" {
# ... configuration
lifecycle {
prevent_destroy = true
}
}
That structured workflow is the key to safe, efficient infrastructure management. With Write, Plan, Apply, and Destroy in place, you have the foundation to use Terraform in practice — next comes installation and your first project.
Installation and first steps
Terraform installation
What do you need for installation? Terraform is a single binary with no complex dependencies. Installation is simple, but you should still pay attention to version management and updates.
Why does a clean installation matter? A well-configured Terraform install saves debugging time later and keeps results consistent across the team. In DevOps environments where different projects need different Terraform versions, a deliberate install is essential.
Installation on Linux
Which installation methods exist? You have several options, each with its own trade-offs. The choice depends on your use case: a one-off install, a team environment, or a development setup with multiple versions.
Installation method comparison:
| Method | Advantages | Disadvantages | Use case |
|---|---|---|---|
| Direct download | Fast, no dependencies | Manual updates, no version management | One-off tests, CI/CD |
| Package manager | Automatic updates, system integration | Often outdated versions | Production servers |
| tfenv | Multi-version, project-specific | Extra complexity | Development environments |
| Docker | Isolated, reproducible | Overhead for local development | CI/CD pipelines |
| Binary in Git | Version control | Repository size | Special workflows |
Direct installation - fast and simple:
The most direct method is downloading from HashiCorp. That is ideal for quick tests or when you need one specific version.
# Determine the current version
TERRAFORM_VERSION=$(curl -s https://api.github.com/repos/hashicorp/terraform/releases/latest | grep tag_name | cut -d '"' -f 4 | sed 's/v//')
# Download and install
wget "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
unzip "terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
sudo mv terraform /usr/local/bin/
sudo chmod +x /usr/local/bin/terraform
# Verification
terraform version
terraform -help
Why this method can become a problem: Updates are manual, there is no version management, and teams quickly drift out of sync. For one-off tests or CI/CD environments it is still a good fit.
Package-manager installation - for production environments:
Package managers provide automatic updates and system integration. That is ideal for server environments where Terraform stays installed permanently.
Ubuntu/Debian setup:
# Install prerequisites
sudo apt update
sudo apt install -y gnupg software-properties-common curl
# Add HashiCorp GPG key
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -
# Add HashiCorp repository
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
# Installation
sudo apt update
sudo apt install terraform
# Verification
terraform version
terraform -help
CentOS/RHEL setup:
# Configure HashiCorp repository
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
# Installation
sudo yum install terraform
# Verification
terraform version
Fedora setup:
# Add HashiCorp repository
sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/fedora/hashicorp.repo
# Installation
sudo dnf install terraform
# Verification
terraform version
Arch Linux:
# Use an AUR helper (e.g. yay)
yay -S terraform
# Or manually from AUR
git clone https://aur.archlinux.org/terraform.git
cd terraform
makepkg -si
Package-manager advantages in practice:
| Aspect | Advantage | Example |
|---|---|---|
| Updates | apt upgrade terraform |
Automatic security updates |
| Dependencies | Resolved automatically | No manual downloads |
| Uninstall | apt remove terraform |
Clean removal |
| Integration | System-wide availability | All users can access it |
| Consistency | Same version on all servers | Infrastructure consistency |
💡 When to use a package manager: Production servers, CI/CD systems, when you only need one Terraform version, or in environments with strict compliance requirements.
⚠️ Package-manager downsides: Versions are often not bleeding-edge. HashiCorp repositories are usually more current than distribution repositories.
Version management with tfenv
What is tfenv and why do you need it? tfenv is a Terraform version manager, similar to rbenv for Ruby or nvm for Node.js. In practice you often have several projects on different Terraform versions. tfenv solves that problem cleanly.
Real scenarios for tfenv:
- Legacy projects: Old project on Terraform 0.12, new one on 1.6
- Team development: Every developer uses exactly the same version
- Testing: Test a new Terraform version in a separate environment
- Client projects: Different customers on different versions
tfenv installation:
# Clone the tfenv repository
git clone https://github.com/tfutils/tfenv.git ~/.tfenv
# Extend PATH permanently
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrc
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.zshrc
# Refresh the current session
source ~/.bashrc # or ~/.zshrc
# Verify the installation
tfenv --version
Remove an existing Terraform installation:
# Remove the system installation
sudo apt remove terraform # Ubuntu/Debian
sudo yum remove terraform # CentOS/RHEL
# Remove a manual installation
sudo rm -f /usr/local/bin/terraform
sudo rm -f /usr/bin/terraform
# Verify that no Terraform binary remains
which terraform # Should return nothing
tfenv commands in detail:
| Command | Purpose | Example | Use |
|---|---|---|---|
tfenv list-remote |
All available versions | tfenv list-remote | head -20 |
Find a version to install |
tfenv install X.Y.Z |
Install a specific version | tfenv install 1.6.0 |
Project-specific version |
tfenv install latest |
Install the newest version | tfenv install latest |
Stay current |
tfenv use X.Y.Z |
Activate a version | tfenv use 1.5.7 |
Switch between projects |
tfenv list |
Show installed versions | tfenv list |
Overview of local versions |
tfenv uninstall X.Y.Z |
Remove a version | tfenv uninstall 1.4.0 |
Clean up old versions |
🔧 Practical example - multi-project setup:
# Install different versions
tfenv install 1.6.0 # Newest for new projects
tfenv install 1.5.7 # For existing projects
tfenv install 1.4.6 # For legacy projects
# Show installed versions
tfenv list
# * 1.6.0 (set by /home/user/.tfenv/version)
# 1.5.7
# 1.4.6
# Project A (new version)
cd ~/projects/project-a
tfenv use 1.6.0
echo "1.6.0" > .terraform-version
terraform version
# Terraform v1.6.0
# Project B (legacy)
cd ~/projects/project-b
tfenv use 1.4.6
echo "1.4.6" > .terraform-version
terraform version
# Terraform v1.4.6
Automatic version selection:
# Create a .terraform-version file
cd ~/projects/my-project
echo "1.5.7" > .terraform-version
# tfenv automatically detects the desired version
tfenv install # Installs 1.5.7 if missing
tfenv use # Activates 1.5.7
# Verification
terraform version
# Terraform v1.5.7
Team workflow with tfenv:
# In the project directory
cd ~/projects/team-project
# Commit .terraform-version
echo "1.6.0" > .terraform-version
git add .terraform-version
git commit -m "Pin Terraform version to 1.6.0"
# Team members can now:
git pull
tfenv install # Automatically installs 1.6.0
tfenv use # Activates 1.6.0
tfenv configuration (.tfenv):
# ~/.tfenv/version for the global default version
echo "1.6.0" > ~/.tfenv/version
# Or via command
tfenv use 1.6.0
Advanced tfenv features:
| Feature | Description | Example |
|---|---|---|
| Version-Regex | Pattern-based installation | tfenv install min-required |
| Latest-Matching | Newest version matching a pattern | tfenv install latest:^1.5 |
| Environment-Override | Environment variable for version | TFENV_TERRAFORM_VERSION=1.6.0 |
| Auto-Install | Automatic install when needed | tfenv install-if-needed |
tfenv vs. other version managers:
| Aspect | tfenv | Docker | Snap |
|---|---|---|---|
| Speed | Very fast | Slower (container) | Medium |
| Isolation | User-level | Fully isolated | Sandboxed |
| Memory use | Minimal | High | Medium |
| Flexibility | Very high | High | Limited |
| Team integration | Excellent | Good | Limited |
💡 tfenv best practices:
- Commit
.terraform-versionto the Git repository- Use specific versions, not latest
- Test new versions on separate branches
- Keep only the versions you actually need
tfenv troubleshooting:
| Problem | Symptom | Solution |
|---|---|---|
| PATH conflicts | Wrong version active | Check which terraform |
| Permission errors | Installation failed | Check ~/.tfenv permissions |
| Version not found | Version not found |
Use tfenv list-remote |
| Slow install | Slow downloads | Configure a mirror server |
First configuration
What belongs in a solid Terraform configuration? After installation you should tune the working environment. That covers plugin caching, logging, and performance.
Why does configuration matter? Without it, Terraform re-downloads every provider for every project, which costs time. A good configuration speeds up daily work considerably.
Terraform configuration file (.terraformrc):
The .terraformrc file is Terraform's central configuration file. It belongs in your home directory and applies globally to all projects.
# Create ~/.terraformrc
cat > ~/.terraformrc << 'EOF'
# Plugin cache directory (saves download time)
plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"
# Disable telemetry (privacy)
disable_checkpoint = true
# Logging configuration
log_level = "INFO"
# Provider installation configuration
provider_installation {
# Local cache has priority
filesystem_mirror {
path = "/home/user/.terraform.d/providers"
include = ["registry.terraform.io/*/*"]
}
# Fallback to direct download
direct {
exclude = []
}
}
# Credentials for private registries
credentials "private-registry.company.com" {
token = "YOUR_TOKEN_HERE"
}
EOF
Set up the plugin cache:
The plugin cache is one of the most important optimisations. Without it, Terraform re-downloads every provider on every terraform init.
# Create the plugin cache directory
mkdir -p ~/.terraform.d/plugin-cache
# Set permissions
chmod 755 ~/.terraform.d/plugin-cache
# Check cache size (after some use)
du -sh ~/.terraform.d/plugin-cache
Environment variables for Terraform:
# Append to ~/.bashrc or ~/.zshrc
cat >> ~/.bashrc << 'EOF'
# Terraform configuration
export TF_DATA_DIR="$HOME/.terraform.d"
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
# Logging (enable only for debugging)
# export TF_LOG=INFO
# export TF_LOG_PATH="./terraform.log"
# Performance tunings
export TF_CLI_ARGS_plan="-parallelism=10"
export TF_CLI_ARGS_apply="-parallelism=10"
# Disable input for CI/CD
# export TF_INPUT=false
# Credential management
export TF_VAR_aws_region="us-west-2"
# export TF_VAR_access_key="your-access-key" # Not recommended!
EOF
# Load the changes
source ~/.bashrc
Environment variable categories:
| Category | Variables | Purpose |
|---|---|---|
| Logging | TF_LOG, TF_LOG_PATH |
Debugging and troubleshooting |
| Performance | TF_CLI_ARGS_* |
Speed optimisation |
| Automation | TF_INPUT, TF_IN_AUTOMATION |
CI/CD integration |
| Credentials | TF_VAR_* |
Variable passing |
| Caching | TF_PLUGIN_CACHE_DIR |
Download optimisation |
Configure shell completion:
# Install Terraform completion
terraform -install-autocomplete
# Manually for different shells
echo 'complete -C terraform terraform' >> ~/.bashrc # Bash
echo 'autoload -U +X bashcompinit && bashcompinit' >> ~/.zshrc # Zsh
echo 'complete -C terraform terraform' >> ~/.zshrc
# Test completion
terraform <TAB><TAB>
# apply console destroy fmt get graph import init output plan providers refresh show state taint untaint validate version workspace
Working-directory structure:
# Organise Terraform projects
mkdir -p ~/terraform-projects/{personal,work,learning}
# Template directory for new projects
mkdir -p ~/terraform-templates/basic-aws
cd ~/terraform-templates/basic-aws
# Create a base template
cat > main.tf << 'EOF'
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
EOF
cat > variables.tf << 'EOF'
variable "aws_region" {
description = "AWS region"
type = string
default = "us-west-2"
}
EOF
cat > outputs.tf << 'EOF'
# Outputs are defined here
EOF
cat > terraform.tfvars.example << 'EOF'
aws_region = "us-west-2"
EOF
Validate the configuration:
# Create a test project
mkdir ~/terraform-test
cd ~/terraform-test
# Minimal configuration
cat > main.tf << 'EOF'
terraform {
required_version = ">= 1.0"
}
output "hello" {
value = "Terraform configuration working!"
}
EOF
# Test the plugin cache
terraform init
# Should show: "Terraform has been successfully initialized!"
# Validate the configuration
terraform validate
# Should show: "Success! The configuration is valid."
# Create a plan
terraform plan
# Should show outputs
# Run apply
terraform apply -auto-approve
# Should show "hello = Terraform configuration working!"
# Clean up
cd ..
rm -rf ~/terraform-test
Performance tunings:
| Optimisation | Configuration | Effect |
|---|---|---|
| Plugin cache | plugin_cache_dir |
90% faster initialisation |
| Parallelism | TF_CLI_ARGS_* |
50% faster deployments |
| Logging | TF_LOG=ERROR |
Less output |
| Checkpoint | disable_checkpoint |
No telemetry delays |
Advanced configuration for teams:
# Team-wide configuration
cat > ~/.terraformrc << 'EOF'
# Plugin cache
plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"
# Disable features for CI/CD
disable_checkpoint = true
# Private registry for company modules
provider_installation {
network_mirror {
url = "https://terraform-mirror.company.com/"
}
}
# Credentials for private registries
credentials "terraform-registry.company.com" {
token = "TEAM_REGISTRY_TOKEN"
}
# Default configuration for all projects
host "app.terraform.io" {
token = "TERRAFORM_CLOUD_TOKEN"
}
EOF
Configuration for CI/CD environments:
# CI/CD-specific environment variables
export TF_IN_AUTOMATION=true
export TF_INPUT=false
export TF_CLI_ARGS_init="-backend-config=bucket=ci-terraform-state"
export TF_CLI_ARGS_plan="-parallelism=3"
export TF_CLI_ARGS_apply="-parallelism=3"
Troubleshooting the configuration:
| Problem | Diagnosis | Solution |
|---|---|---|
| Slow init | Plugin cache not active | TF_LOG=DEBUG terraform init |
| Permission errors | Cache directory | chmod 755 ~/.terraform.d/plugin-cache |
| Completion missing | Shell integration | terraform -install-autocomplete |
| Environment ignored | Variable syntax | export TF_VAR_name=value |
🔧 Practical example - test the complete configuration:
# Test all configurations
terraform version
terraform -help
# Test the plugin cache
ls -la ~/.terraform.d/plugin-cache
# Check environment variables
env | grep TF_
# Test completion
terraform d<TAB> # Should complete to "destroy"
# Create a template project
cp -r ~/terraform-templates/basic-aws ~/terraform-projects/personal/test-project
cd ~/terraform-projects/personal/test-project
terraform init
terraform validate
💡 Configuration best practices:
- Use a plugin cache for better performance
- Disable telemetry for privacy
- Configure shell completion for efficiency
- Organise projects in logical directories
- Use templates for a consistent project structure
With this installation and configuration you have a professional Terraform working environment. The investment pays off in daily work: faster initialisation, better debugging, and consistent results.
Your first Terraform project
Installation is done. The first project is a small but complete infrastructure that demonstrates the important concepts: project layout, provider configuration, and the full workflow from the first line of code to working infrastructure.
Create the project structure
What is a sensible project structure? A deliberate directory layout is the foundation of maintainable Terraform projects. It separates configuration, variables, and outputs logically and makes the project understandable for you and your team.
Why does structure matter so much? Without a clear structure the project quickly becomes messy. As soon as you manage several environments or work in a team, chaotic organisation will cost you.
Standard project structure:
┌─────────────────────────────────────────────────────────────┐
│ STRUCTURE: MY-FIRST-TERRAFORM-PROJECT │
├─────────────────────────────────────────────────────────────┤
│ │
│ my-first-terraform-project/ │
│ ├── main.tf # Resource definitions │
│ ├── variables.tf # Input variables │
│ ├── outputs.tf # Exposed return values │
│ ├── versions.tf # Provider requirements │
│ ├── terraform.tfvars # Concrete values (not Git) │
│ ├── terraform.tfvars.example # Template for the repository │
│ ├── .terraform-version # tfenv version pinning │
│ ├── .gitignore # State and secret exclusion │
│ └── README.md # Project documentation │
│ │
└─────────────────────────────────────────────────────────────┘
🔧 Practical example - create the project:
# Create the project directory
mkdir ~/terraform-projects/my-first-project
cd ~/terraform-projects/my-first-project
# Pin the Terraform version (if using tfenv)
echo "1.6.0" > .terraform-version
# Initialise a Git repository
git init
# Create .gitignore
cat > .gitignore << 'EOF'
# Terraform-specific files
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
*.tfplan
*.tfplan.*
# Sensitive files
terraform.tfvars
*.auto.tfvars
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Crash logs
crash.log
crash.*.log
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
EOF
File purposes in detail:
| File | Purpose | Content | Git status |
|---|---|---|---|
| main.tf | Main configuration | Providers, resources | ✅ Commit |
| variables.tf | Variable definitions | Input parameters | ✅ Commit |
| outputs.tf | Output definitions | Return values | ✅ Commit |
| versions.tf | Provider versions | Version constraints | ✅ Commit |
| terraform.tfvars | Variable values | Real values | ❌ Do not commit |
| terraform.tfvars.example | Example values | Template | ✅ Commit |
| .terraform-version | Terraform version | Version number | ✅ Commit |
Advanced project structure for larger projects:
┌─────────────────────────────────────────────────────────────┐
│ STRUCTURE: ADVANCED-TERRAFORM-PROJECT │
├─────────────────────────────────────────────────────────────┤
│ │
│ advanced-terraform-project/ │
│ ├── environments/ # Environment configuration │
│ │ ├── dev/ # tfvars and backend.tf Dev │
│ │ ├── staging/ # tfvars and backend.tf Stage│
│ │ └── prod/ # tfvars and backend.tf Prod │
│ ├── modules/ # Local modules │
│ │ ├── vpc/ # Network infrastructure │
│ │ ├── security/ # IAM and security groups │
│ │ └── compute/ # EC2 and Auto Scaling │
│ ├── scripts/ # CI/CD helper scripts │
│ ├── docs/ # Architecture documentation │
│ └── tests/ # Automated tests │
│ │
└─────────────────────────────────────────────────────────────┘
Why this structure? Each file has a clear purpose. That makes code reviews easier, reduces merge conflicts, and helps new team members find their way quickly.
💡 Structure best practices:
- Keep
main.tffocused on the main resources- Use meaningful file names
- Separate environments with directories or workspaces
- Document complex decisions in the README
Configure providers:
What is provider configuration? Providers are the interface between Terraform and external APIs. The configuration defines which providers you use, in which version, and with which settings.
Why is provider configuration critical? Without a correct provider configuration Terraform cannot talk to your target infrastructure. Wrong versions can cause unexpected problems.
versions.tf - define provider requirements:
# versions.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.4"
}
}
# Backend configuration (later)
# backend "s3" {
# bucket = "my-terraform-state"
# key = "first-project/terraform.tfstate"
# region = "us-west-2"
# }
}
Provider configuration in main.tf:
# main.tf - provider configuration
provider "aws" {
region = var.aws_region
# Default tags for all resources
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
CreatedAt = timestamp()
}
}
}
# Random provider for unique names
provider "random" {
# No special configuration needed
}
Provider version strategies:
| Constraint | Meaning | Example | Use case |
|---|---|---|---|
= 5.0.0 |
Exact version | version = "= 5.0.0" |
Maximum stability |
>= 5.0.0 |
Minimum version | version = ">= 5.0.0" |
Use new features |
~> 5.0.0 |
Patch updates | version = "~> 5.0.0" |
Security updates |
~> 5.0 |
Minor updates | version = "~> 5.0" |
Feature updates |
>= 5.0, < 6.0 |
Version range | version = ">= 5.0, < 6.0" |
Flexibility with bounds |
💡 Why
~> 5.0is recommended: This constraint allows patch and minor updates (5.1.0, 5.2.0), but not major updates (6.0.0). You get security updates without breaking changes.
Multi-provider configuration:
# Multiple AWS regions
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
provider "aws" {
alias = "us_west_2"
region = "us-west-2"
}
# Cloudflare for DNS
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
Provider authentication:
| Method | Security | Use case |
|---|---|---|
| Environment variables | High | Development, CI/CD |
| AWS Profile | Medium | Local development |
| IAM Roles | Very high | Production environments |
| Hardcoded | Low | Never use this! |
🔧 Practical example - AWS authentication:
# Environment variables (recommended)
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_DEFAULT_REGION="us-west-2"
# Use AWS CLI profiles
aws configure --profile terraform
export AWS_PROFILE=terraform
# Verification
aws sts get-caller-identity
⚠️ Provider security:
- Never hardcode credentials in Terraform files
- Use IAM roles wherever possible
- Apply least-privilege for permissions
- Rotate credentials regularly
Define the first resource
What is a good first resource? For the first project we pick a simple but useful resource: an S3 bucket. It is quick to create, cheap, and demonstrates important Terraform concepts.
Why S3 as the first resource? S3 buckets are easy to understand, have few dependencies, and show important Terraform features such as naming, tagging, and outputs.
variables.tf - define input variables:
# variables.tf
variable "project_name" {
description = "Name of the project"
type = string
default = "my-first-terraform"
validation {
condition = can(regex("^[a-zA-Z0-9-]+$", var.project_name))
error_message = "Project name must contain only alphanumeric characters and hyphens."
}
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "aws_region" {
description = "AWS region"
type = string
default = "us-west-2"
validation {
condition = can(regex("^[a-z0-9-]+$", var.aws_region))
error_message = "AWS region must be a valid region identifier."
}
}
variable "enable_versioning" {
description = "Enable S3 bucket versioning"
type = bool
default = true
}
variable "tags" {
description = "Additional tags for resources"
type = map(string)
default = {}
}
Variable types and validation:
| Type | Example | Validation | Use |
|---|---|---|---|
| string | "us-west-2" |
Regex pattern | Names, regions |
| number | 3 |
Min/max values | Counts, sizes |
| bool | true |
None | Feature flags |
| list(string) | ["a", "b"] |
Length checks | AZs, subnets |
| map(string) | {key = "value"} |
Key pattern | Tags, config |
| object({}) | Complex structure | Nested validation | Configurations |
main.tf - first resources:
# main.tf
# Local calculations
locals {
# Unique bucket name
bucket_name = "${var.project_name}-${var.environment}-${random_id.bucket_suffix.hex}"
# Shared tags
common_tags = merge(
var.tags,
{
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
CreatedAt = timestamp()
}
)
}
# Random ID for unique names
resource "random_id" "bucket_suffix" {
byte_length = 4
}
# Create S3 bucket
resource "aws_s3_bucket" "main" {
bucket = local.bucket_name
tags = merge(
local.common_tags,
{
Name = local.bucket_name
Type = "storage"
}
)
}
# Configure bucket versioning
resource "aws_s3_bucket_versioning" "main" {
bucket = aws_s3_bucket.main.id
versioning_configuration {
status = var.enable_versioning ? "Enabled" : "Disabled"
}
}
# Enable bucket encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "main" {
bucket = aws_s3_bucket.main.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Block public access
resource "aws_s3_bucket_public_access_block" "main" {
bucket = aws_s3_bucket.main.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Upload an example object
resource "aws_s3_object" "welcome" {
bucket = aws_s3_bucket.main.id
key = "welcome.txt"
content = "Hello from Terraform! Created at ${timestamp()}"
tags = local.common_tags
}
Resource dependencies:
┌─────────────────────────────────────────────────────────────┐
│ S3 BUCKET RESOURCE DEPENDENCIES │
├─────────────────────────────────────────────────────────────┤
│ │
│ random_id.bucket_suffix │
│ │ │
│ ▼ │
│ aws_s3_bucket.main ──────────────────────────────┐ │
│ │ │ │
│ ├──► aws_s3_bucket_versioning │ │
│ ├──► aws_s3_bucket_server_side_encryption │ │
│ ├──► aws_s3_bucket_public_access_block │ │
│ │ ▼ │
│ └──────────────────────────────► aws_s3_object │
│ │
└─────────────────────────────────────────────────────────────┘
outputs.tf - define results:
# outputs.tf
output "bucket_name" {
description = "Name of the created S3 bucket"
value = aws_s3_bucket.main.bucket
}
output "bucket_arn" {
description = "ARN of the created S3 bucket"
value = aws_s3_bucket.main.arn
}
output "bucket_region" {
description = "Region of the S3 bucket"
value = aws_s3_bucket.main.region
}
output "bucket_domain_name" {
description = "Domain name of the S3 bucket"
value = aws_s3_bucket.main.bucket_domain_name
}
output "welcome_object_url" {
description = "URL of the welcome object"
value = "s3://${aws_s3_bucket.main.bucket}/${aws_s3_object.welcome.key}"
}
output "project_info" {
description = "Project information"
value = {
name = var.project_name
environment = var.environment
region = var.aws_region
created_at = timestamp()
}
}
terraform.tfvars.example - example configuration:
# terraform.tfvars.example
project_name = "my-first-terraform"
environment = "dev"
aws_region = "us-west-2"
enable_versioning = true
tags = {
Owner = "your-name"
Team = "platform"
}
Why this resource selection?
Each resource demonstrates important Terraform concepts:
- random_id: Shows an external provider
- aws_s3_bucket: Base resource
- Bucket configurations: Dependencies and best practices
- aws_s3_object: Content management
💡 Resource best practices:
- Use
localsfor computed values- Implement validation for critical variables
- Use meaningful resource names
- Enable security features by default
Init, Plan, Apply workflow
What is the practical workflow? Now you run the complete Terraform workflow: initialisation, planning, and apply. That is how the theory feels in practice.
Why step by step? Each step has a purpose and shows you important information. That prevents mistakes and keeps you in control of the process.
Step 1: finalise the configuration
# Change into the project directory
cd ~/terraform-projects/my-first-project
# Check the Terraform version (if using tfenv)
terraform version
# Create the variable file
cp terraform.tfvars.example terraform.tfvars
# Adjust the values
nano terraform.tfvars
terraform.tfvars - real values:
project_name = "my-first-terraform"
environment = "dev"
aws_region = "us-west-2"
enable_versioning = true
tags = {
Owner = "your-name"
Team = "learning"
}
Step 2: Terraform Init
# Initialise Terraform
terraform init
# Expected output:
# Initializing the backend...
# Initializing provider plugins...
# - Finding hashicorp/aws versions matching "~> 5.0"...
# - Finding hashicorp/random versions matching "~> 3.4"...
# - Installing hashicorp/aws v5.31.0...
# - Installing hashicorp/random v3.4.3...
#
# Terraform has been successfully initialized!
What happens during Init:
| Action | Description | Files |
|---|---|---|
| Backend setup | Configure local backend | terraform.tfstate |
| Provider download | Load AWS and Random providers | .terraform/providers/ |
| Module processing | No modules in this project | - |
| Lock file | Pin versions | .terraform.lock.hcl |
Step 3: Terraform Validate
# Validate the configuration
terraform validate
# Expected output:
# Success! The configuration is valid.
Step 4: Terraform Plan
# Create the execution plan
terraform plan
# Save a detailed output to a file
terraform plan -out=tfplan
# Analyse the plan
terraform show tfplan
Plan analysis:
| Symbol | Meaning | Count |
|---|---|---|
+ |
New resource | 6 |
~ |
Change | 0 |
- |
Deletion | 0 |
-/+ |
Replacement | 0 |
Step 5: Terraform Apply
# Apply the changes
terraform apply
# Or with a saved plan
terraform apply tfplan
# Automatic confirmation (tests only)
terraform apply -auto-approve
Apply process:
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
random_id.bucket_suffix: Creating...
random_id.bucket_suffix: Creation complete after 0s [id=oR7_Tg]
aws_s3_bucket.main: Creating...
aws_s3_bucket.main: Creation complete after 2s [id=my-first-terraform-dev-a11ef4e0]
aws_s3_bucket_versioning.main: Creating...
aws_s3_bucket_server_side_encryption_configuration.main: Creating...
aws_s3_bucket_public_access_block.main: Creating...
aws_s3_object.welcome: Creating...
aws_s3_bucket_versioning.main: Creation complete after 1s [id=my-first-terraform-dev-a11ef4e0]
aws_s3_bucket_server_side_encryption_configuration.main: Creation complete after 1s [id=my-first-terraform-dev-a11ef4e0]
aws_s3_bucket_public_access_block.main: Creation complete after 1s [id=my-first-terraform-dev-a11ef4e0]
aws_s3_object.welcome: Creation complete after 1s [id=welcome.txt]
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
Outputs:
bucket_arn = "arn:aws:s3:::my-first-terraform-dev-a11ef4e0"
bucket_domain_name = "my-first-terraform-dev-a11ef4e0.s3.amazonaws.com"
bucket_name = "my-first-terraform-dev-a11ef4e0"
bucket_region = "us-west-2"
project_info = {
"created_at" = "2024-01-15T10:30:00Z"
"environment" = "dev"
"name" = "my-first-terraform"
"region" = "us-west-2"
}
welcome_object_url = "s3://my-first-terraform-dev-a11ef4e0/welcome.txt"
Step 6: verify the result
# Show Terraform state
terraform show
# Show a specific resource
terraform show aws_s3_bucket.main
# Show outputs
terraform output
# Show a specific output
terraform output bucket_name
# AWS CLI for verification
aws s3 ls s3://$(terraform output -raw bucket_name)
aws s3 cp s3://$(terraform output -raw bucket_name)/welcome.txt -
Workflow summary:
| Step | Command | Purpose | Duration |
|---|---|---|---|
| 1. Init | terraform init |
Load providers | 30s |
| 2. Validate | terraform validate |
Check syntax | 2s |
| 3. Plan | terraform plan |
Compute changes | 10s |
| 4. Apply | terraform apply |
Create infrastructure | 30s |
| 5. Verify | terraform show |
Check the result | 5s |
🔧 Practical example - workflow tips:
# Workflow aliases for .bashrc
alias tf='terraform'
alias tfi='terraform init'
alias tfp='terraform plan'
alias tfa='terraform apply'
alias tfs='terraform show'
alias tfo='terraform output'
# Workflow function
tfworkflow() {
echo "🔄 Running Terraform workflow..."
terraform init && \
terraform validate && \
terraform plan && \
read -p "Apply changes? (y/N): " -n 1 -r && \
echo && \
[[ $REPLY =~ ^[Yy]$ ]] && terraform apply
}
Common problems on the first project:
| Problem | Symptom | Solution |
|---|---|---|
| AWS credentials | Error: NoCredentialsError |
Configure AWS credentials |
| Region error | Error: InvalidRegion |
Use a valid AWS region |
| Bucket name | Error: BucketAlreadyExists |
Use a unique name |
| Permissions | Error: AccessDenied |
Check IAM permissions |
| Provider version | Error: version constraint |
Adjust provider versions |
❗ Troubleshooting commands:
# Check AWS configuration
aws configure list
aws sts get-caller-identity
# Terraform debug mode
TF_LOG=DEBUG terraform plan
# Inspect the state file
terraform state list
terraform state show aws_s3_bucket.main
# Check provider versions
terraform providers
💡 First-project learnings:
- The workflow becomes routine over time
- Always read the plan output carefully
- Outputs are valuable for debugging
- The state file is critical — never edit it by hand
Clean up (optional):
# Delete the infrastructure again
terraform destroy
# Confirm with "yes"
# All resources will be deleted
That first project covers Terraform fundamentals: the architecture, the full workflow from configuration to provisioning, and practical HCL syntax. You can now create Terraform projects on your own, configure providers, and manage resources.
Further Resources
After this fundamentals article you have a foundation for your Terraform work. The following resources are the most useful places to go deeper and gain practical experience.
Official documentation
HashiCorp Terraform Documentation
Terraform Developer Hub Terraform Configuration Language Terraform CLI Documentation
Official documentation is the best place for detailed information on every Terraform feature. It is updated regularly and covers both beginner and advanced material.
Provider documentation
AWS Provider Documentation Azure Provider Documentation Google Cloud Terraform Documentation
Terraform Registry
The Terraform Registry is the central place for public providers and modules. You will find thousands of ready-made modules for common infrastructure patterns.
Command Reference (Cheatsheet)
For quick access in day-to-day DevOps and administration work, the following table summarises the most important Terraform commands for workspace initialisation, execution planning, state management, and cleanup:
| Command / syntax | Description and practical use |
|---|---|
terraform init |
Initialises the working directory, downloads providers, and configures the backend. |
terraform init -upgrade |
Updates all providers and modules to the latest allowed versions. |
terraform plan |
Computes the execution plan and shows a detailed preview of the changes (diff). |
terraform plan -out=tfplan |
Saves the computed execution plan deterministically to a file. |
terraform apply |
Executes the changes defined in code and updates the infrastructure and state. |
terraform apply tfplan |
Applies a previously computed and saved execution plan. |
terraform apply -auto-approve |
Runs changes without a manual confirmation prompt (ideal for automated CI/CD pipelines). |
terraform destroy |
Deletes all resources managed by the current Terraform project in a controlled way. |
terraform fmt |
Formats HCL files automatically according to the official HashiCorp style guidelines. |
terraform fmt -check |
Checks in a CI/CD run whether all configuration files are correctly formatted. |
terraform validate |
Validates configuration syntax and consistency of variable types. |
terraform show |
Shows the current infrastructure state or a plan in readable form. |
terraform state list |
Lists all resource addresses managed in the current state file. |
terraform state show <resource> |
Shows all attributes and values of a specific resource from state. |
terraform state mv <source> <target> |
Renames resources in state without recreating them in the cloud. |
terraform state rm <resource> |
Removes a resource from state without physically deleting it in the cloud. |
terraform output |
Prints all defined output values of the current infrastructure. |
terraform refresh |
Reconciles local state with the actual cloud infrastructure (drift detection). |
terraform workspace list |
Lists all available workspaces (e.g. dev, staging, prod). |
terraform workspace select <name> |
Switches to another existing Terraform workspace. |
terraform workspace new <name> |
Creates a new workspace with an isolated state file. |
terraform force-unlock <lock-id> |
Releases a stuck state lock (emergency command after aborted runs). |
terraform version |
Shows the installed Terraform version and the active provider plugins. |
Further Resources
The following official documentation, books, practical tools, and certification guides go deeper into the Infrastructure as Code concepts covered above:
| Resource | Description |
|---|---|
| HashiCorp Terraform Documentation | Official technical documentation on HCL, providers, modules, and CLI commands. |
| HashiCorp Get Started Tutorials | Interactive getting-started tutorials for AWS, Azure, Google Cloud, and Docker. |
| Terraform Best Practices | Official guide for professional project structures and state management. |
| Terraform: Up & Running (Y. Brikman) | Standard work for productive Terraform use in teams. |
| Terraform in Action (S. Winkler) | Practical book focused on serverless, multi-cloud, and scaling patterns. |
| The Terraform Book (J. Turnbull) | Practical handbook for system administrators and platform engineers. |
| Hands-On Terraform Foundations | Practice-oriented video course with interactive exercises on CLI usage. |
| Terraform 101 course | Compact fundamentals course on HCL syntax and declaration patterns. |
| Terraform + AWS specialisation course | Deep-dive course for cloud architectures with VPC, EC2, IAM, and S3. |
| terraform-docs | Automated generation of module documentation in Markdown or YAML. |
| terraform-docs GitHub | Open-source repository and CLI tool for terraform-docs. |
| HashiCorp Certified: Terraform Associate | Official exam objectives and preparation guide for the certification (003). |
| 37 preparation tutorials for the certification | Targeted HashiCorp practice scenarios for the Terraform Associate exam. |
💡 Tip: Start with the official documentation and the free resources. Once you have practical experience, the deeper books are a worthwhile investment for architectural understanding.
Conclusion
Infrastructure as Code with Terraform is not a trend — it is how infrastructure is managed now. You have the knowledge used daily in companies worldwide: from the theory through installation to the first working project.
The investment in Terraform skills pays off immediately — less manual work, more consistency, and a clearly lower error rate. Your infrastructure becomes versioned code, treated the same way as your applications.
💡 Coming next: Part two of the series covers advanced HCL syntax, complex AWS scenarios, professional state management, and proven best practices — how to run Terraform in production environments and larger teams.
👉 Overview: All DevOps articles and guides