In this episode we'll discuss three fundamental Terraform blocks: providers as the bridge to cloud APIs, resources as the building blocks of infrastructure, and data sources to read existing infrastructure without having to recreate it.

After discussing HCL syntax anatomy and the four core workflow steps of Terraform in episode 2 — terraform init, terraform plan, terraform apply, and terraform destroy — in this episode we'll get to the heart of everything: providers, resources, and data sources. Without understanding these three, all those workflow commands are just meaningless movements, like owning a race car but not knowing how to press the pedals.
Why is this topic crucial in the real working world? Because almost all of a DevOps or Cloud Engineer's work starts with writing a resource block to create VMs, buckets, databases, or networks — then reading existing infrastructure through data sources so you don't accidentally create duplicate resources. Small mistakes at this layer, like choosing the wrong provider version or misspelling a resource type, can result in inflated cloud bills or configurations that can't be applied at all.
In this episode you'll not only learn the syntax, but also why Terraform needs providers, what happens behind terraform init when it downloads providers, and when to use resource versus data source. Let's get started.
Literally, a provider is a supplier. In the Terraform context, a provider is a plugin that acts as a bridge between the HCL code you write and the cloud provider API (AWS, GCP, Azure, and others).
Imagine you're bringing a universal travel adapter when going abroad. Your laptop plug speaks HCL, while the cloud power outlet in another country speaks different REST APIs — different endpoints, different authentication schemes, different data shapes. Without the adapter, there's no electricity flow. The provider is exactly like that adapter: it knows precisely how to turn a resource "aws_instance" declaration into a series of HTTP requests to the AWS API.
Important implications of this concept:
terraform CLI) is just an engine that executes what providers instruct. This is what makes Terraform cloud-agnostic: as long as a provider exists for that platform, Terraform can manage it with the same language.hashicorp/aws, for Google Cloud hashicorp/google, for Azure hashicorp/azurerm, and even for Docker there's kreuzwerker/docker.ls -la .terraform/providers/registry.terraform.io/hashicorp/aws/drwxr-xr-x 5 dev staff 160 Aug 2 10:14 .
drwxr-xr-x 3 dev staff 96 Aug 2 10:14 ..
drwxr-xr-x 3 dev staff 96 Aug 2 10:14 5.74.0Important
Providers are not part of the Terraform installation. They are downloaded separately during terraform init from the Terraform Registry and stored in the local .terraform/ directory. Therefore, every time you add a new provider to your configuration, you must run terraform init again.
required_providers vs the provider BlockThere are two blocks that beginners often confuse, even though their roles differ:
terraform { required_providers { ... } } — registers which providers this project needs, from which source, and which version. It's like the project's "dependency list".provider "aws" { ... } — configures that provider, for example region, credential profile, or endpoint for a custom cloud.Usually the two blocks are separated into different files for neatness, for example versions.tf and provider.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}Several things to note:
source is the provider's full address in the Registry, formatted as namespace/name. hashicorp/aws means the official provider owned by HashiCorp. Be wary of unofficial providers with similar names — this has been a supply chain attack vector in the open-source ecosystem.version uses a version constraint. ~> 5.0 means "the latest 5.x version but don't jump to 6.x". This preserves reproducibility: the code running on your laptop will use the same provider version in CI/CD later.aws providers with different aliases for different regions). This is useful for multi-region architectures.Tip
Even though the provider block can be empty (credentials are read from the environment), always declare region explicitly. Never rely on AWS_DEFAULT_REGION, which can differ between machines — a wrong region can mean infrastructure built on the wrong continent.
terraform init: Downloading ProvidersWhen you run terraform init, Terraform reads the required_providers block, then downloads the matching provider plugins from the Terraform Registry into the .terraform/ directory. Pay attention to these lines in the output:
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.74.0...
- Installed hashicorp/aws v5.74.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include that 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.
Terraform has been successfully initialized!There are two outputs you must understand:
Installed hashicorp/aws v5.74.0 (signed by HashiCorp) — Terraform verifies the provider's digital signature before using it, as a security layer..terraform.lock.hcl — a lock file that records the exact provider versions used. This file must be committed to Git so all team members use identical versions.Caution
Never commit the .terraform/ directory to Git — it's large and machine-specific. But you must commit the .terraform.lock.hcl file. An easy way to remember: folders can disappear, lockfiles can't.
If providers are the adapter, then resources are the building materials — real objects you want to create in the cloud, such as EC2 instances, S3 buckets, RDS databases, or security groups. This is the block you'll write most often in your IaC career.
Its basic syntax is:
resource "resource_type" "resource_name" {
argument_1 = "value"
argument_2 = "value"
}Notice that resource uses two labels:
| Label | Function | Example |
|---|---|---|
resource_type | The resource type from the provider's perspective | aws_instance, aws_s3_bucket |
resource_name | Local logical name (only known within your code) | web, artifacts |
The combination of both (aws_instance.web) becomes a reference address that other resources can use. This is the basis of implicit dependency which we'll discuss deeper in episode 7.
Here's a complete aws_instance resource example:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
Env = "production"
}
}There are two categories of attributes in a resource:
instance_type, tags). These are what you write inside the block.id, arn, and public_ip. You don't write them, but you can read them for other resources to reference, for example aws_instance.web.public_ip.Note
Misspelling an attribute inside a resource often doesn't produce a syntax error — Terraform will just treat it as an extra argument and complain during terraform plan. Always run terraform validate after writing resources to catch this problem early.
To give you a sense of the breadth of the Terraform ecosystem, here are some of the providers most commonly encountered in the working world:
| Provider | Source (Registry) | Used for |
|---|---|---|
| AWS | hashicorp/aws | EC2, S3, VPC, RDS, IAM, EKS |
| Azure | hashicorp/azurerm | VM, Blob Storage, AKS, Virtual Network |
| Google Cloud | hashicorp/google | Compute Engine, GKE, Cloud Storage, VPC |
| Kubernetes | hashicorp/kubernetes | Managing workloads in a K8s cluster (deployments, services) |
| Docker | kreuzwerker/docker | Containers, images, network in a Docker daemon |
| Cloudflare | cloudflare/cloudflare | DNS, CDN, WAF, Zero Trust |
Tip
You can run more than one provider in a single project — for example setting up a DNS record in Cloudflare for a domain that points to an AWS load balancer. Terraform will build the cross-provider dependency graph automatically.
There are times when you don't need to create a resource because the object already exists — created manually by another team, or it's infrastructure that must not be duplicated (company VPC, built-in AMIs, already-partitioned subnets). That's where data sources come in.
A data source is a block that only reads (read-only) information from the cloud without creating or changing anything. Think of the difference this way: a resource is us building a house from scratch, while a data source is us checking the address database of houses already registered at the local administration office.
Its syntax is similar to a resource, only the keyword is data:
data "data_source_type" "data_source_name" {
filter = "criteria"
}The most classic example is finding the latest Ubuntu AMI for our instance:
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}owners contains the official Canonical (the makers of Ubuntu) AWS account ID, and filter narrows down the search results. With most_recent = true, Terraform picks the newest matching AMI — so you don't need to manually check the AMI ID in the console every time a new release comes out.
Next, the data source result can be referenced with the data.<type>.<name>.<attribute> address:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}Notice the data.aws_ami.ubuntu.id reference — the data keyword in front marks that it belongs to a data source, not a resource.
Important
The most important difference: a resource creates and manages an object's lifecycle (it can be updated and destroyed), whereas a data source only reads and will never be deleted by Terraform. Using data for something that should be a resource (or vice versa) is one of the most common architectural mistakes in Terraform code.
As a wrap-up to this discussion, let's string it all together: a data source to find an AMI, a resource to create an instance, and one cross-block reference:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-southeast-1"
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}
output "web_public_ip" {
value = aws_instance.web.public_ip
}The execution flow: terraform init downloads the AWS provider → terraform plan shows that one AMI will be read (data source) and one instance will be created (resource) → terraform apply creates the real instance in AWS and displays its public IP.
| Mistake | Symptom | Solution |
|---|---|---|
Forgetting required_providers | Deprecated warning & ambiguously resolved provider | Always declare source and version |
Not running terraform init after adding a provider | Error provider.aws: no suitable version installed | Re-run terraform init |
| Misspelling resource attribute names | Error during terraform plan, not validate | Use terraform validate + editor extension |
| Hardcoding AMI IDs that are rarely refreshed | Instance uses an old OS version | Use the aws_ami data source |
| Data source returns many results without a filter | Error Your query returned more than one result | Add most_recent = true or a more specific filter |
Forgetting alias when using multiple providers | Resource created in the wrong region | Declare alias-ed providers and reference them via provider = aws.west |
In this episode 3 we discussed Terraform's three fundamental pillars: providers as the bridge from HCL to cloud APIs along with the required_providers mechanism and the terraform init that downloads them; resources as the building blocks of infrastructure with the resource "type" "name" syntax; and data sources to read existing infrastructure without recreating it.
The core of this episode is understanding who talks to the cloud (providers), what gets created (resources), and what gets read (data sources). With this foundation, you can now create your first Terraform configuration that actually produces real infrastructure in the cloud.
In the next episode 4, we'll make this configuration much more flexible and reusable with Input Variables, Local Values & Output Values — so the same set of code can be used for both staging and production environments. Stay excited!