Dive into the heart of OpenTofu HCL: provider declarations, writing resource blocks, and fetching data from existing infrastructure via data sources. After this episode, you can read and write your first correct, reviewable OpenTofu configuration.

In episode 2 we ran the core workflow and looked at the OpenTofu Registry. You already know how tofu init downloads providers and how tofu plan produces a preview. The question now: how does OpenTofu know which resources to create? The answer lies in the declarations inside .tf files — the topic of episode 3.
In this episode we cover three declaration foundations: the provider block (connecting OpenTofu to cloud APIs), the resource block (something to be created), and the data source (something that already exists whose data is fetched). When you're done, you'll be able to read any OpenTofu configuration with confidence.
A provider is a plugin that translates OpenTofu requests into cloud API calls. The analogy is a printer driver: OpenTofu doesn't need to know how to talk to AWS — the hashicorp/aws provider does that.
Provider declarations happen at two levels. First, the required_providers block locks down the source and version:
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}source points to the registry location; version = "~> 5.0" means any 5.x version. Second, the provider block configures an instance, e.g. region and credentials:
provider "aws" {
region = "ap-southeast-1"
}Note
For GCP you'd write source = "hashicorp/google" and provider "google" { project = var.project_id }. The pattern is identical — only the provider name differs. The entire provider ecosystem follows the same contract.
Once the provider is ready, you declare a resource: one concrete object in the cloud. The syntax is always resource "TYPE" "NAME" { ... } — the type is determined by the provider, and the name is yours to choose as an internal label.
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}A resource reads like a sentence: an EC2 instance named web, using the AMI from the ubuntu data source, t3.micro type, with a Name tag of web-server. If you run tofu apply, OpenTofu calls the provider to create this instance in the cloud.
A resource block can also carry meta-arguments like count, for_each, and depends_on — covered in detail in episode 8. For now, understand the declaration concept.
Not all infrastructure is created by OpenTofu. Sometimes you need a reference to an existing resource — an AMI, a default VPC, or an IAM role — without managing its lifecycle. That's where data sources come in: they read (read-only), and create nothing.
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-*-amd64-server-*"]
}
owners = ["099720109477"]
}A data source produces attributes that can be referenced from resources. Notice in the resource example above: ami = data.aws_ami.ubuntu.id — it takes the AMI id found by the lookup.
The key to telling them apart: resource = OpenTofu creates it; data source = OpenTofu reads it.
Tip
Use data sources for things that rarely change but are referenced often — the latest AMI, default subnets, or security groups managed by another team. This saves you from hardcoding IDs that quickly go stale.
To see all three working together, here's the structure of a simple project:
lab-opentofu/
├── versions.tf # terraform block + required_providers
├── providers.tf # provider configuration
├── data.tf # data source (reads existing infrastructure)
└── main.tf # resources to be createdThe flow: tofu init downloads the provider according to versions.tf, tofu plan reads data sources and compares resources against state, then tofu apply creates resources in the cloud. These three files are the standard pattern you'll see in almost every professional IaC repository.
Summary of episode 3:
terraform { required_providers } block and the provider block — source plus version in the first block, instance configuration in the second.resource "type" "name" { ... } declares a cloud object fully managed by OpenTofu.data "type" "name" { ... } reads existing data, and its results can be referenced like resource attributes.You can now write correct, well-structured configurations. In the next episode, episode 4, we'll make that code more flexible and reusable with input variables, local values, and output values — the tools to remove hardcoding from configuration. See you there!