Learn Terraform - Providers, Resources & Data Sources
Episode 3 of 21

Learn Terraform - Providers, Resources & Data Sources

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.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

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.

Main Discussion

What Is a Terraform Provider and Why Do We Need It?

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:

  1. Terraform knows nothing about any cloud. The Terraform core (the 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.
  2. Each cloud needs a separate provider. For AWS we use hashicorp/aws, for Google Cloud hashicorp/google, for Azure hashicorp/azurerm, and even for Docker there's kreuzwerker/docker.
  3. Providers are code, not just a concept. Providers contain thousands of lines of Go logic that define resource types, make API calls, and synchronize the real condition into state.
Exploring the provider location after terraform init
ls -la .terraform/providers/registry.terraform.io/hashicorp/aws/
Output (example)
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.0

Important

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.

Declaring Providers: required_providers vs the provider Block

There 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.
  • There can be more than one provider, even for the same cloud (for example two 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.

What Happens During terraform init: Downloading Providers

When 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:

terraform init output (truncated)
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:

  1. Installed hashicorp/aws v5.74.0 (signed by HashiCorp) — Terraform verifies the provider's digital signature before using it, as a security layer.
  2. .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.

Resource Blocks: The Building Blocks of Infrastructure

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:

hcl
resource "resource_type" "resource_name" {
  argument_1 = "value"
  argument_2 = "value"
}

Notice that resource uses two labels:

LabelFunctionExample
resource_typeThe resource type from the provider's perspectiveaws_instance, aws_s3_bucket
resource_nameLocal 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:

main.tf
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:

  • Arguments — values you specify as the desired state (for example instance_type, tags). These are what you write inside the block.
  • Computed attributes — values generated by the cloud, not determined by you, for example 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:

ProviderSource (Registry)Used for
AWShashicorp/awsEC2, S3, VPC, RDS, IAM, EKS
Azurehashicorp/azurermVM, Blob Storage, AKS, Virtual Network
Google Cloudhashicorp/googleCompute Engine, GKE, Cloud Storage, VPC
Kuberneteshashicorp/kubernetesManaging workloads in a K8s cluster (deployments, services)
Dockerkreuzwerker/dockerContainers, images, network in a Docker daemon
Cloudflarecloudflare/cloudflareDNS, 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.

Data Sources: Reading, Not Creating

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:

hcl
data "data_source_type" "data_source_name" {
  filter = "criteria"
}

The most classic example is finding the latest Ubuntu AMI for our instance:

data.tf
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:

main.tf
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.

Combined Example: Data Source + Resource + Output

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:

main.tf
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.

Common Mistakes in Providers, Resources & Data Sources

MistakeSymptomSolution
Forgetting required_providersDeprecated warning & ambiguously resolved providerAlways declare source and version
Not running terraform init after adding a providerError provider.aws: no suitable version installedRe-run terraform init
Misspelling resource attribute namesError during terraform plan, not validateUse terraform validate + editor extension
Hardcoding AMI IDs that are rarely refreshedInstance uses an old OS versionUse the aws_ami data source
Data source returns many results without a filterError Your query returned more than one resultAdd most_recent = true or a more specific filter
Forgetting alias when using multiple providersResource created in the wrong regionDeclare alias-ed providers and reference them via provider = aws.west

Conclusion

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!

Learn Terraform - Providers, Resources & Data Sources | Learn Terraform