Skip to main content

Terraform basics: providers, resources, plan & apply

Time to write real Infrastructure as Code. Terraform (and its identical-for-learning twin OpenTofu) is the dominant IaC tool: you describe your desired infrastructure in files written in a language called HCL, then run a short workflow to make the cloud match. By the end of this lesson you'll be able to read any Terraform file and understand the plan/apply cycle that every change goes through.

HCL: the language you write infrastructure in

Terraform configuration is written in HCL (HashiCorp Configuration Language) — a declarative language designed to be human-readable. You don't write step-by-step instructions; you declare blocks that describe what should exist. The two block types you'll use most are providers and resources.

Providers: the plugin for each cloud

A provider is a plugin that teaches Terraform how to talk to a specific platform's API — there's an AWS provider, a Google Cloud provider, an Azure provider, and hundreds more. You declare which provider(s) you need and configure them (e.g. which region to operate in). This is what makes Terraform cloud-agnostic: the workflow is identical everywhere; you just swap the provider.

# Tell Terraform we want the AWS provider, configured for one region.
provider "aws" {
region = "eu-central-1"
}

Resources: the things you want to exist

A resource block declares a single piece of infrastructure you want — a VM, a bucket, a database, a network. Its shape is always the same:

resource "<TYPE>" "<NAME>" {
# arguments that configure this resource
}
  • <TYPE> is the kind of thing (e.g. aws_s3_bucket), defined by the provider.
  • <NAME> is your label for referring to it elsewhere in your code (not the real cloud name).
  • Inside, you set arguments — the actual configuration.

Here's a real, minimal example — an object-storage bucket (the S3 primitive from Chapter 2), declared as code:

resource "aws_s3_bucket" "assets" {
bucket = "my-app-assets-2026"

tags = {
Environment = "production"
ManagedBy = "terraform"
}
}

Read it as: "There should exist an S3 bucket named my-app-assets-2026, tagged as production and managed by Terraform." You declared the destination; you said nothing about how to create it. That's declarative IaC.

Variables and outputs: don't hard-code, don't guess

Two more blocks make configurations reusable and useful:

  • Variables (variable) are inputs — values you don't want to hard-code, like the region or environment name. You reference them with var.name. This lets one configuration produce prod and staging by feeding different variable values.
  • Outputs (output) are values Terraform reports back after creating things — like the URL of a load balancer it just made, which you couldn't know in advance.
variable "environment" {
description = "Which environment this is (e.g. staging, production)"
type = string
default = "staging"
}

resource "aws_s3_bucket" "assets" {
bucket = "my-app-assets-${var.environment}" # interpolates the variable
}

output "bucket_name" {
value = aws_s3_bucket.assets.bucket # report the name back to us
}

Notice aws_s3_bucket.assets.bucket — that's how blocks reference each other: <type>.<name>.<attribute>. When one resource references another, Terraform automatically figures out the correct order to create them (the dependency graph). You never write "create A before B"; you just reference, and Terraform infers the order. That's the declarative model paying off.

The workflow: init → plan → apply

Every Terraform change follows the same three-step ritual, and the middle step is the one that makes Terraform safe to use.

terraforminit\ndownloadproviders,\nset upterraform plan\nshowEXACTLY whatwill\nchange — a dryHuman reviews\ntheplanterraformapply\nmake thecloud matchedit the .tf fileslooks rightwrong
  1. terraform init — one-time setup for a project: downloads the provider plugins you declared and prepares the working directory.
  2. terraform plan — the safety net. Terraform compares your code (desired state) against what currently exists (real state) and shows you exactly what it will create, change, or destroywithout doing anything yet. It's a dry run. You read it like a diff:
    Plan: 1 to add, 0 to change, 0 to destroy.
  3. terraform apply — executes the plan, making the real API calls so the cloud matches your code. It shows the plan again and asks for confirmation first.

:::tip Always read the plan terraform plan is the single most valuable habit in IaC. It tells you the consequences of your change before they happen — including the dangerous ones. If a plan says 1 to destroy and you didn't expect to destroy anything, stop: you're about to delete real infrastructure. The plan is your last chance to catch a mistake; never apply a plan you haven't read. :::

Trace this Terraform (practice)

IaC mostly can't run in a browser sandbox — it talks to real cloud APIs — so the skill to practice is reading a plan, exactly what you'll do daily on the job. Work through this in your head:

variable "env" {
type = string
default = "staging"
}

resource "aws_s3_bucket" "logs" {
bucket = "acme-logs-${var.env}"
tags = { team = "platform" }
}

output "where" {
value = aws_s3_bucket.logs.bucket
}

Question 1: With the default variable, what bucket name gets created? Question 2: If you run terraform apply once, then run terraform plan again without changing the file, what will the plan say? Question 3: You then run with env = "production". What does the plan show?

Answers
  1. acme-logs-staging — the ${var.env} interpolates the default staging.
  2. No changes. Your infrastructure matches the configuration. — Terraform is idempotent: the desired state already matches reality, so there's nothing to do. (This is the declarative model from the last lesson in action.)
  3. Bucket names can't be renamed in place, so Terraform plans to destroy acme-logs-staging and create acme-logs-production: Plan: 1 to add, 0 to change, 1 to destroy. This is exactly the kind of destructive plan you must read carefully before applying.

Why it matters

Terraform configurations are HCL files made of providers (the per-cloud plugin) and resources (the things you declare should exist), parameterized with variables and reporting back outputs, with resources referencing each other so Terraform infers creation order automatically. Every change runs init → plan → apply, and plan — the dry run that shows exactly what will be added, changed, or destroyed — is your essential safety net. You can now read real IaC. But Terraform has to remember what it already built to compute those diffs, and how it remembers is the single most important — and most dangerous — concept in Terraform: state.

Next: State & drift →