Infrastructure provisioning used to be a manual, error‑prone chore. Today, Terraform lets you describe your entire stack in code, version it, and spin it up with a single command. This guide walks you through an end‑to‑end automation workflow—perfect for teams that need repeatable, auditable, and scalable cloud environments.
What You’ll Need
- Terraform ≥ 1.5 installed locally (or via a CI runner)
- Access to a cloud provider (AWS, Azure, or GCP) with appropriate IAM permissions
- Git for version control and a remote repository (GitHub, GitLab, etc.)
- Basic knowledge of HCL (HashiCorp Configuration Language)
- A text editor or IDE with Terraform plugins (VS Code, JetBrains, etc.)
Step 1: Install and Verify Terraform
Download the binary for your OS from HashiCorp’s site. After extraction, move it to a directory in your PATH and confirm the installation:
terraform version
# Expected output: Terraform v1.5.x If you see a version mismatch, double‑check the PATH and ensure no older Terraform binary shadows the new one.
Step 2: Set Up a New Terraform Project
Create a dedicated folder for your infrastructure code and initialize it as a Git repository:
mkdir my‑infra && cd my‑infra
git init
Inside the folder, create a main.tf file that declares the provider. For AWS, it looks like this:
provider "aws" {
region = "us-east-1"
}
Run terraform init to download the provider plugins and set up the backend (local by default). This command also creates a .terraform directory that stores state‑related metadata.
Step 3: Define Your First Resource
Let’s provision a simple VPC with a public subnet. Append the following HCL to main.tf:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "terraform‑vpc"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
tags = {
Name = "terraform‑public-subnet"
}
}
Validate the syntax without applying changes:
terraform validate If validation passes, generate an execution plan to see what Terraform intends to create:
terraform plan -out=tfplan.out Review the plan carefully—this is your safety net before any real resources are provisioned.
Step 4: Store State Remotely
Local state files (terraform.tfstate) are fine for experiments, but production workloads demand a remote backend for collaboration and locking. Below is an example using an S3 bucket with DynamoDB for state locking:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/infra.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Run terraform init again. Terraform will ask to migrate the existing state to the new backend—confirm with yes. From now on, the state lives securely in S3, and concurrent runs are prevented by DynamoDB locks.
Step 5: Modularize Your Code
As your infrastructure grows, monolithic main.tf files become hard to maintain. Split logical components into reusable modules. Create a modules/vpc directory with its own main.tf, variables.tf, and outputs.tf:
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr
tags = var.tags
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidr
availability_zone = var.az
tags = var.tags
}
# modules/vpc/variables.tf
variable "cidr" { type = string }
variable "public_subnet_cidr" { type = string }
variable "az" { type = string }
variable "tags" { type = map(string) }
# modules/vpc/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
output "public_subnet_id" { value = aws_subnet.public.id }
Reference the module from your root configuration:
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
public_subnet_cidr = "10.0.1.0/24"
az = "us-east-1a"
tags = {
Name = "terraform‑vpc"
}
}
Modules encourage DRY (Don’t Repeat Yourself) principles, simplify testing, and make it trivial to reuse the same VPC pattern across environments (dev, staging, prod).
Step 6: Integrate with CI/CD
Automation reaches its full potential when Terraform runs are part of a pipeline. Below is a minimal GitHub Actions workflow (.github/workflows/terraform.yml) that validates, plans, and applies changes on merges to the main branch:
name: Terraform CI
on:
push:
branches:
- main
jobs:
terraform:
runs-on: ubuntu-latest
env:
AWS_REGION: us-east-1
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.5.0
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
run: terraform init -input=false
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -out=tfplan.out
- name: Terraform Apply
if: github.ref == 'refs/heads/main'
run: terraform apply -auto-approve tfplan.out
Store all secrets (AWS keys, backend bucket names, etc.) in the repository’s secret store—never hard‑code them. With this pipeline, every approved change is automatically vetted and applied, guaranteeing consistent environments.
Common Mistakes to Avoid
Even seasoned engineers stumble. Here are the pitfalls that most cause painful rollbacks:
- Hard‑coding credentials: Embedding access keys in
.tffiles leads to credential leakage. Use environment variables,aws_profile, or secret managers. - Ignoring state locking: Running parallel
applycommands without a lock can corrupt the state file. Always configure a backend that supports locking (S3+DynamoDB, GCS+Firestore, etc.). - Over‑using
countfor resource loops: Whilecountis handy, complex conditional logic becomes unreadable. Preferfor_eachwith maps for clearer intent. - Neglecting
terraform fmtandvalidatein CI: Unformatted code or syntactic errors slip through without automated checks, causing pipeline failures. - Storing state in the repository: Committing
terraform.tfstateexposes resource IDs, passwords, and can cause merge conflicts. Keep state out of version control.
Tips and Tricks
Boost productivity with these seasoned practices:
- Use
terraform console: Quickly evaluate expressions, test interpolation, or inspect data sources without a full run. - Leverage workspaces for environments:
terraform workspace new devcreates isolated state files, letting you reuse the same configuration across dev, staging, and prod. - Pin provider versions: In
required_providers, specify exact versions to avoid surprise breaking changes. - Adopt Sentinel or OPA policies: Enforce guardrails (e.g., disallow public S3 buckets) before
applyproceeds. - Enable detailed logs: Set
TF_LOG=DEBUGwhen troubleshooting obscure provider errors.
Frequently Asked Questions
Can I use Terraform to manage existing resources?
Yes. Import existing resources with terraform import, then generate the corresponding HCL manually or with terraform state show. After import, run terraform plan to ensure the code matches the live state.
How do I handle secrets like database passwords?
Never store secrets in plain text. Use a secret manager (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) and reference them via the external data source or provider‑specific secret data sources. Combine with kms encryption for state files.
What’s the difference between terraform apply and terraform destroy?
apply creates or updates resources to match the desired state, while destroy removes every resource tracked in the state file. Use destroy with caution—pair it with a targeted -target flag if you only need to tear down a subset.
Conclusion
Terraform transforms infrastructure from a manual checklist into a versioned, testable codebase. By installing Terraform, structuring your project, modularizing, securing state, and wiring everything into CI/CD, you achieve true infrastructure automation—fast, repeatable, and auditable. Keep an eye on common mistakes, adopt the tips above, and you’ll spend more time delivering value and less time firefighting cloud drift.




