It is 11 PM at a company like Zerodha. Production is down. An engineer opens the AWS console to check the load balancer configuration, and nothing matches what is in the runbook. Someone changed a security group rule three weeks ago, by hand, to unblock a deploy, and never wrote it down. Nobody remembers who. Nobody remembers why. This is **configuration drift** - the gap between what your infrastructure actually is and what anyone believes it to be. Console clicking has no history, no review step, and no way to reliably reproduce what you built. It works fine for one engineer exploring one service. It fails the moment a team, a second environment, or a 2 AM incident enters the picture. **Infrastructure as Code (IaC)** means describing your infrastructure in text files that a tool can read and apply, instead of clicking buttons by hand. The files live in version control. Changes go through a pull request, just like application code. A tool computes what would change before anything actually changes. Console clicking Infrastructure as Code ----------------- ----------------------- No reliable history Full git history No review before applying Pull request review Hard to reproduce an environment Reproducible from files Drift can remain hidden Drift can be detected by plan > **Note:** This module assumes you already know AWS core concepts (regions, AZs, shared responsibility) from the cloud fundamentals module, and that you are comfortable with basic AWS services like EC2 and VPC at a conceptual level. This module does not re-teach those. ### What you should be able to do after this module * Explain what problem IaC solves that console clicking and scripting cannot * Run the Terraform core workflow (`init`, `plan`, `apply`, `destroy`) and explain what each step actually does * Distinguish a Terraform resource from a data source, and configure a provider * Explain why local state breaks in a team and set up remote state with S3 and DynamoDB locking * Write reusable configuration using variables, outputs, and locals * Package a reusable pattern as a Terraform module * Choose between Terraform workspaces and separate state files for managing multiple environments * Recognize CloudFormation's template structure and know when a team would reach for it or CDK instead of Terraform -
**Terraform** is a tool that reads text files describing desired infrastructure and figures out what API calls to make to get AWS (or any other provider) into that state. You do not write the steps - you describe the destination, and Terraform works out the path. The full professional workflow is `fmt`, `validate`, `init`, `plan`, `apply`, `destroy` - not just the last four. ### What fmt and validate actually do `terraform fmt` rewrites your files into Terraform's standard formatting, so every engineer's diffs stay clean and reviewable. `terraform validate` checks that your configuration is syntactically correct and internally consistent, without checking it against real AWS state. ```bash ## Rewrites files into consistent formatting - run before every commit terraform fmt ## Checks syntax and internal consistency - catches typos early terraform validate ``` > **Note:** `fmt` and `validate` are cheap, fast checks that catch mistakes before you ever call AWS's APIs. Most CI/CD pipelines run both before `plan`, since a badly formatted or invalid config wastes a plan cycle. ### What init actually does `terraform init` prepares a working directory for use. It downloads the provider plugins your configuration references (like the AWS provider), and sets up the backend where state will be stored. ```bash ## Run this once per working directory, and again after ## adding a new provider or changing the backend config terraform init ``` > **Note:** A **provider** is a plugin that translates Terraform's generic resource language into actual API calls for a specific platform - the AWS provider knows how to call EC2 and S3 APIs, the Google provider knows how to call GCP APIs. ### What plan actually does `terraform plan` compares three things: your configuration files, the current state file, and the real infrastructure via API calls. It then prints exactly what it would create, change, or destroy - without touching anything yet. ```bash ## Shows a diff of what would change - nothing is applied terraform plan ``` ```text Terraform will perform the following actions: # aws_instance.web will be created + resource "aws_instance" "web" { + ami = "ami-0abcdef1234567890" + instance_type = "t3.micro" } Plan: 1 to add, 0 to change, 0 to destroy. ``` > 📌 **Remember:** `plan` is the review step that console clicking never gave you. Reading the plan output before every apply is the single habit that prevents most IaC incidents - a plan that says "1 to destroy" when you expected zero changes is your last chance to stop before it happens. ### What apply and destroy actually do `terraform apply` runs a plan, asks for confirmation, and then makes the real API calls to create the diff. `terraform destroy` does the same thing in reverse - it plans and then removes every resource Terraform is tracking in that state. ```bash ## Applies the plan after you type "yes" to confirm terraform apply ## Tears down everything Terraform manages in this state ## Use with real caution - this is not reversible terraform destroy ``` > ⚠️ **Security:** Never run `terraform destroy` against a production state file without triple-checking which directory and which backend you are pointed at. Two engineers have taken down production by running destroy in the wrong terminal tab. -
### Configuring the AWS provider Every Terraform configuration that talks to AWS needs a provider block telling Terraform which region and credentials to use. ```hcl provider "aws" { region = "ap-south-1" ## Mumbai region - keeps latency low for Indian users } ``` > **Note:** Terraform picks up AWS credentials the same way the AWS CLI does - environment variables, a shared credentials file, or an IAM role if running on EC2. Never hardcode access keys directly in a `.tf` file, since those files are usually committed to git. ### Pinning provider versions Without a version constraint, Terraform can pull in a newer provider release that changes behavior underneath you. A **required_providers** block pins the provider source and version range so upgrades are a deliberate choice, not a surprise. ```hcl terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" ## allows 5.x, blocks a breaking 6.0 upgrade } } } ``` > 📌 **Remember:** Pin provider versions on any configuration more than one person touches. An unpinned provider means `terraform init` can silently fetch a different version on your teammate's laptop than on yours. ### Resources - things Terraform creates and manages A **resource** block tells Terraform to create and manage a real object - an EC2 instance, an S3 bucket, a security group. Terraform owns the full lifecycle of anything declared as a resource. ```hcl resource "aws_instance" "web" { ami = "ami-0abcdef1234567890" instance_type = "t3.micro" tags = { Name = "prod-mumbai-web-01" } } ``` ### Data sources - things Terraform only reads A **data source** reads information about something that already exists, without creating or managing it. Use this to reference an existing VPC, an existing AMI, or an existing Route 53 zone that some other process owns. ```hcl data "aws_vpc" "existing" { tags = { Name = "prod-mumbai-vpc" ## Looks up a VPC that already exists } } ``` > 🔴 **Common Mistake:** Declaring something as a `resource` when it actually already exists and is managed elsewhere. This causes Terraform to try to create a duplicate, or worse, to "adopt" and later destroy infrastructure another team depends on. If you are only referencing something, use a `data` block, not a `resource` block. ### Understanding dependencies between resources Terraform builds a **dependency graph** from your configuration and creates or destroys resources in the correct order automatically, without you writing any steps. ```hcl resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" } resource "aws_subnet" "private" { vpc_id = aws_vpc.main.id ## referencing this creates an implicit dependency cidr_block = "10.0.1.0/24" } ``` > **Note:** This is called an **implicit dependency** - Terraform sees `aws_vpc.main.id` referenced inside the subnet block and knows the VPC must exist first. Most dependencies in real configurations are implicit and need no extra code. Occasionally a dependency exists that Terraform cannot see through a reference - like an IAM policy that must exist before an application can start, with no attribute connecting the two blocks. `depends_on` makes that hidden dependency explicit. ```hcl resource "aws_instance" "web" { ami = "ami-0abcdef1234567890" instance_type = "t3.micro" depends_on = [aws_iam_role_policy.web_policy] ## no attribute reference exists otherwise } ``` > 🔴 **Common Mistake:** Reaching for `depends_on` as a default habit. It should be rare - almost every real dependency is already implicit through an attribute reference. Adding it everywhere makes the dependency graph harder to read without adding real safety. ### Managing multiple similar resources with count and for_each **count** creates a fixed number of indexed copies of a resource. **for_each** creates one resource per entry in a map or set, each addressed by a key instead of a number. ```hcl ## count - indexed instances, referenced as aws_instance.web[0], [1], [2] resource "aws_instance" "web" { count = 3 ami = "ami-0abcdef1234567890" instance_type = "t3.micro" } ## for_each - keyed instances, referenced by name, not position resource "aws_subnet" "private" { for_each = var.subnets cidr_block = each.value.cidr availability_zone = each.value.az } ``` > **Note:** Prefer `for_each` over `count` whenever items have meaningful names, like subnets per Availability Zone. With `count`, removing item `[1]` from the middle of a list shifts every index after it, which can make Terraform destroy and recreate resources that did not actually change. `for_each` keys are stable, so removing one entry only affects that entry. -
### Why state exists at all Terraform needs to track which real-world objects correspond to which resource blocks in your configuration. That mapping is called **state**, and by default it is written to a local file called `terraform.tfstate`. ```text Your .tf files Terraform state Real AWS infrastructure (desired) <-> (last known mapping) <-> (actual resources) ``` ### Why local state breaks the moment a team is involved A local state file lives on one engineer's laptop. The moment a second engineer runs `terraform apply` from their own laptop, they have no idea what the first engineer already created, and Terraform has no way to prevent two applies from running at the same time and corrupting each other's changes. > 🔴 **Common Mistake:** Committing `terraform.tfstate` to git as a way to "share" it. State files often contain sensitive values in plain text, and git gives you no locking - two people can still edit and push conflicting state at the same time, corrupting it. ### Remote state with an S3 backend The standard fix is a **remote backend**: state lives in an S3 bucket instead of on a laptop, so every engineer and every CI job reads and writes the same state. ```hcl terraform { backend "s3" { bucket = "zerodha-terraform-state-mumbai" key = "prod/network/terraform.tfstate" region = "ap-south-1" encrypt = true use_lockfile = true ## native S3 locking - current recommended approach } } ``` > **Note:** Modern Terraform supports native S3 state locking (`use_lockfile`), which uses a lock file inside the same S3 bucket instead of a separate service. This is the current recommended approach for new configurations, since it means one less piece of infrastructure to run. Engineer A: terraform apply | +-- Acquires lock file in S3 | Engineer B: terraform apply (same state) | +-- Blocked until Engineer A's lock releases ### DynamoDB locking - the older pattern you will still see Before native S3 locking existed, the standard pattern was a separate DynamoDB table holding lock records, and you will still encounter this in older codebases and most existing tutorials. ```hcl terraform { backend "s3" { bucket = "zerodha-terraform-state-mumbai" key = "prod/network/terraform.tfstate" region = "ap-south-1" dynamodb_table = "terraform-locks" ## legacy locking mechanism encrypt = true } } ``` > 💡 **Tip:** If you are starting a new configuration today, prefer native S3 locking over standing up a DynamoDB table. If you are working in an existing codebase that already uses `dynamodb_table`, there is no urgent need to migrate it - both mechanisms prevent the same concurrent-apply problem. ### State is sensitive infrastructure data, not an implementation detail State files frequently contain plaintext values pulled from resource attributes - database endpoints, sometimes even passwords set via Terraform - so the S3 bucket holding state needs the same protection as any other sensitive data store. Terraform | v S3 State Bucket | +-- Encryption at rest +-- Versioning enabled +-- Least-privilege IAM access +-- Public access blocked +-- Access logging enabled > ⚠️ **Security:** Enable S3 bucket versioning on your state bucket. If state ever gets corrupted or an apply goes wrong, versioning lets you recover the previous state file instead of losing your entire mapping between configuration and real infrastructure. Combine this with blocking all public access on the bucket and granting access through least-privilege IAM policies, not broad account-wide permissions. ### State inspection and operations Beyond `terraform state list`, Terraform ships other state subcommands for inspecting and, carefully, editing what is tracked. ```bash ## Show full details of one tracked resource terraform state show aws_instance.web ## Move a resource to a new address without destroying/recreating it terraform state mv aws_instance.web aws_instance.web_primary ## Remove a resource from state without destroying the real infrastructure terraform state rm aws_instance.old ``` > 🔴 **Common Mistake:** Editing state directly to "fix" a problem without understanding what each subcommand actually does. `state rm` does not delete the real AWS resource - it only stops Terraform from tracking it, which means a future `apply` may try to recreate something that still exists. Treat manual state edits as a last resort, not a routine tool. -
### Why "don't hardcode access keys" is not enough Knowing not to hardcode credentials only answers half the question - Cloud Engineers also need to know what to use instead, both for authenticating Terraform itself and for secrets that end up inside the infrastructure it creates. Terraform | +--> AWS IAM Role (authenticates Terraform to AWS) | +--> Secrets Manager (app secrets, rotated) | +--> SSM Parameter Store (config values, some encrypted) ### Referencing a secret instead of writing it into configuration ```hcl data "aws_secretsmanager_secret_version" "db_password" { secret_id = "prod-mumbai-rds-password" ## looks up the secret at apply time } resource "aws_db_instance" "primary" { password = data.aws_secretsmanager_secret_version.db_password.secret_string } ``` > 🔴 **Common Mistake:** Putting a database password or API key directly into a Terraform variable or a `.tfvars` file, reasoning that the file is not committed to git. That value still lands in plaintext inside the state file, and `.tfvars` files get committed by accident far more often than teams expect. Pull secrets from Secrets Manager or SSM Parameter Store at apply time instead of writing them into any `.tf` or `.tfvars` file. -
### Variables - making configuration reusable A **variable** lets you parameterize configuration instead of hardcoding values, so the same files can be reused across environments. ```hcl variable "instance_type" { description = "EC2 instance type for the web tier" type = string default = "t3.micro" } resource "aws_instance" "web" { ami = "ami-0abcdef1234567890" instance_type = var.instance_type ## references the variable above } ``` ### Outputs - exposing values to other configs or humans An **output** exposes a value after apply completes - useful for values you need to hand off, like an instance's public IP, or as input to another Terraform configuration. ```hcl output "web_instance_ip" { value = aws_instance.web.public_ip ## printed after terraform apply } ``` ### Locals - naming a computed value once A **local** lets you compute or name a value once and reuse it, instead of repeating the same expression across multiple resource blocks. ```hcl locals { common_tags = { Environment = "prod" ManagedBy = "terraform" } } resource "aws_instance" "web" { ami = "ami-0abcdef1234567890" instance_type = var.instance_type tags = local.common_tags ## reused across many resources } ``` -
It is 11 PM at a company like Zerodha. Production is down. An engineer opens the AWS console to check the load balancer ...
Terraform is a tool that reads text files describing desired infrastructure and figures out what API calls to make to ge...
Configuring the AWS provider Every Terraform configuration that talks to AWS needs a provider block telling Terraform wh...
Why state exists at all Terraform needs to track which real-world objects correspond to which resource blocks in your co...
Why "don't hardcode access keys" is not enough Knowing not to hardcode credentials only answers half the question - Clou...
Variables - making configuration reusable A variable lets you parameterize configuration instead of hardcoding values, s...
Why modules exist A module is a reusable, packaged set of Terraform configuration - like a function in a programming lan...
The realistic scenario this solves A company like Razorpay often has real AWS infrastructure that was built by hand mont...
Both patterns solve the same problem - managing dev, staging, and prod without one environment accidentally affecting an...
CloudFormation is AWS's own native IaC service - templates in YAML or JSON that AWS itself parses and applies. CloudForm...
The AWS Cloud Development Kit (CDK) lets you write infrastructure in Python, TypeScript, or other general-purpose langua...
A short preview here - the dedicated CI/CD module covers this in depth. The core pattern almost every team converges on:...
Factor Favors Terraform Favors CloudFormation/CDK Multi-cloud or hybrid Yes - one tool, many providers No - AWS only Tea...
Install Terraform and initialize a working directory. Success looks like: init completes with "Terraform has been succes...
Command/Concept What it does terraform fmt Rewrites files into consistent formatting terraform validate Checks syntax an...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.