Terraform fundamentals: IaC for Linux administrators and DevOps

Terraform fundamentals for Linux administrators: installation, workflow and a first AWS project, explained in practice.

Reading time: 45 min

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.

As usual you will find special markers throughout:
* 💡 Tips and notes: For a transparent and efficient way of working
* ⚠️ Warnings and pitfalls: For destructive or changing commands
* 🔧 Practical examples: To follow along directly
* ❗ Typical failure modes: With an explanation of the cause

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 revert and 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.0 is 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_on only 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: WritePlanApplyDestroy. 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 init after every change to providers or modules
  • Commit .terraform.lock.hcl to Git for reproducible builds
  • Use -upgrade only 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 plan before terraform apply
  • Save important plans with -out for later use
  • Use -target only 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-version to 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.tf focused 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.0 is 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 locals for 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

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

Share & export

Export as Markdown

Related posts