Quick answer: Terraform configurations are built from a small set of building blocks — providers, resources, data sources, variables, outputs, modules and state — written in HashiCorp Configuration Language (HCL). Once you understand what each block does and how they reference each other, you can read almost any Terraform project, no matter how large.
This guide introduces every core concept with a working example, shows the HCL syntax rules that trip up newcomers, and ends with a complete mini-project that ties the pieces together. You need no prior Terraform experience, only a basic idea of what a cloud server or storage bucket is.
First, the mental model. Everything in Terraform revolves around three things. Your .tf files describe the desired state. The state file records what Terraform created last time. A plan is the calculated difference between the two. terraform apply executes that difference through provider plugins, then updates the state. Keep this triangle in mind and each concept below will slot into place.
Providers: the plugins that talk to platforms
Terraform’s core binary understands HCL and dependency graphs, nothing else. A provider is a plugin that knows how to call a specific API — AWS, Azure, Google Cloud, Kubernetes, GitHub, Cloudflare. You declare which providers you need and how to configure them:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-south-1"
}
Credentials never go here. The AWS provider picks them up from environment variables, the ~/.aws config, or an IAM role attached to the machine. We covered provider internals in more depth in Top Advantages of Using Terraform for Infrastructure as Code.
Resources and data sources: creating versus reading
A resource block describes one object Terraform should manage. It has a type (which provider and which object), a local name you choose, and a body of arguments:
resource "aws_s3_bucket" "assets" {
bucket = "tkh-assets-2025"
tags = {
Environment = "dev"
Owner = "platform-team"
}
}
The type aws_s3_bucket is prefixed with the provider name, so Terraform always knows which plugin handles it. The local name assets exists only inside your code; elsewhere you refer to this bucket as aws_s3_bucket.assets, and to its attributes as aws_s3_bucket.assets.arn or aws_s3_bucket.assets.id.
A data source, by contrast, looks up information without creating anything. The classic example is finding the latest AMI so you never hard-code an image ID:
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}
Data sources are referenced with the data. prefix. Use them to read VPCs created by another team, your current account ID (aws_caller_identity), or secrets stored in a vault.
Variables and outputs: inputs and results
Input variables make a configuration reusable across environments. Outputs expose values after apply, both to humans and to other Terraform configurations.
variable "environment" {
type = string
description = "Deployment environment name"
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
output "bucket_name" {
value = aws_s3_bucket.assets.bucket
description = "Name of the assets bucket"
}
Variables are referenced as var.environment and can be set from a terraform.tfvars file, the -var flag, or TF_VAR_environment environment variables. The full story, including types, locals and functions, is in Using Variables and Expressions in Terraform.
Modules: reusable bundles of configuration
Any folder of .tf files is a module. The folder you run commands in is the root module; it can call child modules from a local path, Git, or the Terraform Registry:
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
environment = var.environment
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = module.network.public_subnet_id
}
A module’s outputs are read as module.<name>.<output>. Modules are how teams avoid copying the same 40 lines of VPC code into every project.
State: Terraform’s memory
After every apply, Terraform writes terraform.tfstate, a JSON file mapping each resource in your code to a real object ID in the cloud. It is how Terraform knows that aws_instance.web is i-0a1b2c3d and that nothing needs to change on the next run. State can contain sensitive values, so in teams it is stored in a remote backend (S3, Azure Blob, GCS, HCP Terraform) with locking, never in Git.
HCL syntax rules you must know
| Element | Syntax | Example |
|---|---|---|
| Argument | name = value |
instance_type = "t3.micro" |
| Block | type "label" { ... } |
resource "aws_vpc" "main" { } |
| Nested block | No = sign |
tags { } vs. tags = { } (map argument) |
| Reference | type.name.attribute |
aws_vpc.main.id |
| Interpolation | "text ${expr}" |
"web-${var.environment}" |
| Comment | # or // or /* */ |
# Mumbai region |
| Lists and maps | [ ] and { } |
["a", "b"], { key = "v" } |
Two habits save hours: run terraform fmt to standardise spacing, and terraform validate to catch typos before you plan. Terraform reads every .tf file in the directory and merges them, so splitting into main.tf, variables.tf and outputs.tf is purely for human readability.
Common beginner mistakes
- Confusing the resource type with the local name.
aws_instanceis fixed by the provider;webis yours. Renamingweblater makes Terraform think you deleted one resource and created another. - Using the old
"${var.x}"interpolation everywhere. Since Terraform 0.12 you writevar.xdirectly; wrap in${}only inside a longer string. - Hard-coding IDs that a data source could look up, such as AMIs or VPC IDs that differ between accounts.
- Committing
terraform.tfstateto Git. It leaks secrets and causes merge conflicts. Use a remote backend and add*.tfstate*to.gitignore. - Skipping
plan.terraform applyshows a plan too, but reading it in a hurry at the approval prompt is how people destroy databases.
Frequently asked questions
Is HCL the same as JSON or YAML?
No. HCL is HashiCorp’s own language, designed to be readable by humans while also being machine-friendly. Terraform does accept a JSON variant (.tf.json) for generated configs, but almost everyone writes HCL.
What is the difference between a resource and a data source?
A resource is created, updated and destroyed by Terraform. A data source only reads an existing object. If you delete a data block, nothing in the cloud is removed.
Do I need a separate state file per environment?
Yes, in practice. Use separate backend paths or workspaces for dev, staging and prod so that a mistake in one cannot affect the others.
Which Terraform version should I learn on?
Any 1.x release. The language has been stable since 1.0 (2021), and newer versions only add features such as import blocks, moved blocks and native testing.
Key takeaways
- Providers connect Terraform to APIs; resources create things; data sources read things.
- Variables make configs reusable, outputs expose results, modules package everything for reuse.
- State is the link between code and reality — store it remotely and never in Git.
- HCL has only a handful of syntax rules;
terraform fmtandvalidateenforce them for you.
Ready to turn these concepts into real AWS and Azure deployments? Our DevOps course covers Terraform end to end with live projects, mentor support and placement assistance. Prefer video? Follow along on our YouTube channel.


