Learn Terraform in a Single Post: Complete Tutorial From HCL and State to Modules, Providers, and Infrastructure as Code

Terraform is infrastructure as code: you describe the cloud resources you want — servers, databases, networks, DNS — in declarative config files, and Terraform creates, updates, and destroys them to match. No more clicking around the AWS console, no more “who created this EC2?”, no more drift between what exists and what’s documented. This single post teaches the whole tool in five stages, with hand-drawn diagrams and runnable HCL.

Learning Roadmap

Terraform Learning Roadmap

The roadmap moves from the language (Stage 1), through the state that tracks reality (Stage 2), reusable modules (Stage 3), the day-to-day workflow (Stage 4), and the advanced/managed layer (Stage 5). You’ll want Docker and Git context — IaC is the ops analog of those.


Stage 1 — Basics: HCL, Resources, Providers

What Terraform is

Terraform is a declarative tool: you write what infrastructure you want, Terraform figures out how to create/update/destroy it. It works across hundreds of providers (AWS, GCP, Azure, Kubernetes, GitHub, Datadog…) through a uniform interface — the same resource block shape for everything.

HCL — HashiCorp Configuration Language

# main.tf
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "uploads" {
  bucket = "my-app-uploads-prod"
  tags   = { Environment = "prod" }
}

resource "aws_instance" "web" {
  ami           = "ami-0c7217cdde317cf16"
  instance_type = "t3.micro"
  tags          = { Name = "web-server" }
}

The building blocks:

  • terraform {} — Terraform settings (required providers, backend).
  • provider "aws" {} — configures a provider (region, credentials).
  • resource "TYPE" "NAME" {} — the core unit: one piece of infrastructure. TYPE is provider-defined (aws_s3_bucket), NAME is your label (referenced as aws_s3_bucket.uploads).
  • variable "NAME" {} — input (Stage 3).
  • output "NAME" {} — exported value (Stage 3).
  • data "TYPE" "NAME" {} — read an existing resource you didn’t create with Terraform.

Attributes reference each other

resource "aws_instance" "web" {
  ami           = "ami-0c7217cdde317cf16"
  instance_type = var.instance_type        # variable
  subnet_id     = aws_subnet.public.id    # attribute of another resource
  tags          = { Name = "web" }
}

Resources expose attributes you reference with TYPE.NAME.ATTRIBUTE — wiring resources together (a subnet ID into an instance).

Pitfall: HCL is declarative — order of blocks in the file doesn’t matter; Terraform computes a dependency graph. Don’t try to “order” your resources top-to-bottom like a script; reference attributes and let Terraform figure out the order.


Stage 2 — State

What state is and why it matters

Terraform needs to know what it already created so it can diff your config against reality. That record is the state file (terraform.tfstate) — a JSON map of every resource Terraform manages, its IDs, and its attributes.

State: The Source of Truth + Backends

# store state remotely (DO THIS in any team)
terraform {
  backend "s3" {
    bucket         = "my-tf-state"
    key            = "prod/main.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks"   # locking
    encrypt        = true
  }
}
  Local state Remote backend
Where terraform.tfstate in cwd S3/GCS/Azure blob/TF Cloud
Shared no yes (team)
Durable lost if laptop dies durable, encrypted
Locking none (race conditions) yes (DynamoDB/table-based)
Secrets in it yes — gitignore it! encrypted at rest

Pitfall: The state file contains secrets (resource passwords, tokens) and is the source of truth for your infrastructure. Never commit it to Git (add to .gitignore); always use a remote backend with locking. If two people apply concurrently without locking, you corrupt state.

Drift and import

  • Drift — someone changed a resource outside Terraform (in the console), so state doesn’t match reality. terraform plan detects it (shows an unexpected diff); terraform apply reconciles it back to your config.
  • terraform import — adopt an existing resource (created by hand or another tool) into Terraform state, so Terraform manages it going forward without recreating it:
terraform import aws_s3_bucket.uploads my-app-uploads-prod
# then add the matching resource block to your .tf file

Stage 3 — Modules

Why modules

Copy-pasting resource blocks across environments (dev/staging/prod) doesn’t scale. A module is a reusable, parameterized bundle of resources — a function for infrastructure.

Modules: Variables, Outputs, Composition

Module structure

modules/
  vpc/
    variables.tf    # inputs
    main.tf          # the resources
    outputs.tf       # exports
# modules/vpc/variables.tf
variable "cidr"         { type = string }
variable "name"         { type = string }
variable "azs"          { type = list(string) }

# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block = var.cidr
  tags       = { Name = var.name }
}

# modules/vpc/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }

Use a module

# main.tf (root module)
module "vpc" {
  source = "./modules/vpc"
  cidr   = "10.0.0.0/16"
  name   = "prod-vpc"
  azs    = ["us-east-1a", "us-east-1b"]
}

resource "aws_instance" "web" {
  subnet_id = module.vpc.vpc_id   # read a module output
}

source can be a local path, a Git URL, or a Terraform Registry module (e.g. terraform-aws-modules/vpc/aws). The Registry has thousands of community-maintained, tested modules — don’t reinvent a VPC module; use the canonical one.

Composition

The root module composes child modules, passing one module’s output as another module’s variable. This is how you build a full stack (VPC → DB → app → DNS) from small, reusable pieces.

Pitfall: A module that’s too big (a “kitchen sink”) is worse than copy-paste. Keep modules small and focused — one module per concern (VPC, DB, app, DNS). Compose them at the root.


Stage 4 — The Workflow

init → plan → apply → destroy

Terraform Workflow: init -> plan -> apply -> destroy

terraform init      # download providers, init the backend
terraform fmt       # format the .tf files (like gofmt)
terraform validate  # static check of syntax + internal consistency
terraform plan      # DRY RUN: show what will change (+ / - / ~)
terraform apply     # execute the plan (prompts "yes")
terraform destroy   # tear everything down

plan is the most important command. Always review the plan before apply — it shows exactly what will be created (+), changed (~), or destroyed (-). In a team, plan in CI (Terraform Cloud / a PR check) so a human reviews the diff before it runs.

terraform plan -out=tfplan     # save the plan
terraform apply tfplan          # apply exactly that plan (no re-plan, no surprises)

Pitfall: Running terraform apply without reviewing plan first is how prod gets taken down. In any team environment, run plan in CI on every PR and require a human review of the diff before apply.


Stage 5 — Advanced

Workspaces (use sparingly)

terraform workspace new staging
terraform workspace select prod

A workspace is a named slice of the same state — the same config, different state file per workspace (dev/staging/prod). Use sparingly: workspaces hide which environment you’re hitting, which is dangerous. Most teams prefer separate directories + variable files (envs/prod/, envs/staging/) — explicit, harder to mistake.

Providers — hundreds available

Provider Manages
aws, google, azurerm cloud compute/network/DB
kubernetes, helm K8s resources + Helm releases
github repos, teams, actions secrets
datadog, pagerduty monitoring/alerting

A data source reads an existing resource you didn’t create with TF (an AMI, a zone ID):

data "aws_ami" "latest" { most_recent = true; filter { name = "name"; values = ["amzn2-ami-*"] } }
resource "aws_instance" "web" { ami = data.aws_ami.latest.id }

Provisioners (last resort)

resource "aws_instance" "web" {
  # ...
  provisioner "remote-exec" {
    connection { host = self.public_ip, type = "ssh", user = "ec2-user" }
    inline = ["sudo yum install -y nginx"]
  }
}

Provisioners run commands after a resource is created. They’re a last resort — they’re not tracked in state, so they don’t re-run on update and can’t be cleanly undone. Prefer configuration management (Ansible, user-data, a Packer-built AMI) over provisioners.

Terraform Cloud / Enterprise

Terraform Cloud (managed) and Terraform Enterprise (self-hosted) add:

  • Remote runsapply happens on Terraform’s servers, not your laptop, with the plan shown in a UI for review.
  • CI/CDplan on every PR, gated apply.
  • Policy as codeSentinel / OPA policies that block non-compliant plans (e.g. “no S3 bucket without encryption”).
  • Workspaces + RBAC — per-team, per-env access control.

For solo/small teams, the CLI + a remote backend is enough. Terraform Cloud’s free tier covers small teams and adds the PR-plan review that’s worth its weight in gold.

The ecosystem

Providers, Workspaces, Import, Cloud

Concern Tool
Providers aws, google, azurerm, kubernetes, github, datadog
Workspaces env switching (prefer dirs + var files)
Advanced import (adopt), provisioners (last resort), data (read existing), depends_on
Platforms Terraform Cloud (managed), Terraform Enterprise (self-hosted), tfenv (version manager), Sentinel/OPA (policy)

Quick-Start Checklist

  1. Install Terraform (brew install terraform / download); verify terraform -v.
  2. Set cloud creds in env (AWS_ACCESS_KEY_ID, etc.) — never in .tf files.
  3. Write a 1-resource main.tf — an S3 bucket — and run init.
  4. Run plan and read every line before apply.
  5. Use a remote backend with locking for any team project.
  6. Gitignore terraform.tfstate and any *.tfstate.backup.
  7. Extract a module for anything you reuse (VPC, DB, app).
  8. Use Registry modules for standard patterns (don’t reinvent a VPC).
  9. Run plan in CI (PR check) and require human review before apply.
  10. terraform destroy when done with a sandbox — don’t leave resources running.

Common Pitfalls

  • Committing state to Git — it has secrets and is the source of truth; gitignore it and use a remote backend.
  • No backend locking — concurrent apply corrupts state; use DynamoDB/table locks.
  • apply without reviewing plan — how prod dies; review every line, especially - (destroy).
  • Hardcoding secrets in .tf — use variables from env / a secrets manager; never literals.
  • Big “kitchen-sink” modules — keep modules focused; compose at the root.
  • Reinventing standard modules — use the Terraform Registry’s canonical VPC/DB modules.
  • Provisioners for config — they’re not idempotent or tracked; use user-data/Packer/Ansible.
  • Workspaces for prod vs dev — too easy to mis-target; prefer separate dirs + var files.
  • Ignoring drift — run plan regularly; reconcile console changes back to config (or import then adopt).

Further Reading

Terraform is the IaC layer of the DevOps stack — these PyShine tutorials connect to it:


Terraform’s value is that infrastructure becomes reviewable, versioned, repeatable — a diff in a PR instead of a click in a console. The five stages here — HCL, state, modules, the workflow, advanced — cover everything from one S3 bucket to a multi-environment, module-composed, CI-gated infrastructure. The two habits that pay off forever: always review plan before apply (it’s the only thing between you and a destroyed prod database), and treat state as the source of truth — remote backend, locking, gitignored. Write a main.tf with one bucket, run initplanapply, then destroy it — once you’ve seen a resource appear and disappear from a config file, the model clicks.

Watch PyShine on YouTube

Contents