NovuSpark
All articles
TerraformAugust 15, 2025 · NovuSpark Team

Terraform 101: Providers, State, and Your First Resource

This is the first post in our Terraform fundamentals series. Later posts cover variables and outputs, modules, remote state, and workspaces.

Most Terraform tutorials start with syntax. That's backwards. HCL (HashiCorp Configuration Language) is genuinely simple — the part that actually trips people up later is the mental model underneath it: what a provider is, what state actually represents, and why Terraform behaves the way it does when those two things disagree with each other.

This post builds that model by provisioning one real resource, end to end.

What Terraform actually does

Terraform is a declarative provisioning tool. You describe the infrastructure you want in configuration files, and Terraform figures out the sequence of API calls needed to make reality match that description — whether that means creating something from nothing, updating an existing resource in place, or tearing something down.

The critical word there is declarative. You're not writing a script that says "create a VPC, then create a subnet, then create an instance." You're writing a description of the end state, and Terraform's dependency graph figures out the order.

The dependency graph: how order actually gets decided

This is worth making concrete, because "Terraform figures out the order" sounds like magic until you see it working on more than one resource.

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}
 
resource "aws_subnet" "web" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}
 
resource "aws_instance" "web" {
  ami           = "ami-0c1a7f89451184c8b"
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.web.id
}

Nowhere in this file does it say "create the VPC first." Terraform infers that ordering entirely from the references: aws_subnet.web references aws_vpc.main.id, so the subnet implicitly depends on the VPC; aws_instance.web references aws_subnet.web.id, so the instance depends on the subnet. Terraform builds a directed acyclic graph (DAG) from these references and walks it, creating the VPC first, then the subnet, then the instance — and, importantly, it can create independent resources (two subnets with no reference between them, say) in parallel, because the graph tells it there's no ordering constraint between them.

aws_vpc"main"aws_subnet"web"aws_instance"web"vpc_idsubnet_idapply order: left to right — each arrow is a reference, not a manual instruction
Fig. 1 — the dependency graph Terraform infers purely from resource attribute references
terraform graph | dot -Tpng > graph.png

terraform graph outputs this dependency graph in a format Graphviz can render — genuinely useful the first time you want to actually see the shape of a real configuration's dependencies, rather than trying to hold it in your head.

Providers: how Terraform talks to the outside world

Terraform itself has no built-in knowledge of AWS, Azure, Kubernetes, or anything else. That knowledge lives in providers — plugins that translate HCL into API calls for a specific platform.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
 
provider "aws" {
  region = "eu-west-2"
}

The required_providers block pins which provider and version range you depend on — this matters more than it looks. Provider APIs change between major versions, and an unpinned provider can silently start behaving differently on someone else's machine or in CI, weeks after you wrote the config. Pin it the same way you'd pin a language runtime version.

Run terraform init after writing this, and Terraform downloads the AWS provider plugin into a local .terraform/ directory. Nothing has touched AWS yet — init only sets up the tooling.

The lock file: pinning goes one level deeper than you'd expect

init also writes a .terraform.lock.hcl file, which is worth understanding rather than ignoring:

provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.31.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:abc123...",
    "zh:def456...",
  ]
}

~> 5.0 in your configuration allows any 5.x version. The lock file pins the exact version actually resolved and installed (5.31.0 here), plus cryptographic hashes of the provider binary itself. This is the same reasoning as a package-lock.json alongside a looser package.json version range, covered elsewhere on this blog: the configuration expresses acceptable flexibility; the lock file guarantees everyone — every teammate, every CI run — gets the exact same provider build, not just a build that satisfies the same loose constraint. Commit .terraform.lock.hcl to version control. Not committing it is a surprisingly common mistake that quietly reintroduces the "works on my machine" risk the lock file exists to prevent.

Your first resource

A resource block is where you actually declare something you want to exist.

resource "aws_instance" "web" {
  ami           = "ami-0c1a7f89451184c8b"
  instance_type = "t3.micro"
 
  tags = {
    Name = "terraform-101-example"
  }
}

The syntax is resource "<provider_type>" "<local_name>". aws_instance tells Terraform which provider resource type this is; web is a name you choose, used only to refer to this resource elsewhere in your own configuration — it never appears in AWS itself.

Run terraform plan. Terraform reaches out to AWS (read-only), compares what it finds against your configuration, and prints exactly what it intends to change:

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami           = "ami-0c1a7f89451184c8b"
      + instance_type = "t3.micro"
      + id            = (known after apply)
      ...
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Always read the plan before applying it. This is the single habit that prevents the most expensive Terraform mistakes — a plan that says "1 to destroy" on a resource you didn't expect is a warning, not a formality to skip past. Pay particular attention to the symbol prefix on each line: + for create, ~ for update in place, and -/+ for destroy-and-recreate — a distinction that matters enormously, because some attribute changes (like changing an EC2 instance's availability_zone) can't be applied in place and force AWS to tear down and rebuild the resource entirely, which is a very different operational event than a quiet in-place update.

Run terraform apply, confirm, and Terraform creates the EC2 instance and reports back.

State: the part nobody explains well

Here's the question every Terraform tutorial glosses over: how does terraform plan know what currently exists, without you telling it?

The answer is a file called terraform.tfstate, created automatically the moment you first apply. It's a JSON record mapping every resource in your configuration to the real-world object it corresponds to. It's worth actually looking at one:

{
  "version": 4,
  "terraform_version": "1.7.5",
  "resources": [
    {
      "mode": "managed",
      "type": "aws_instance",
      "name": "web",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
      "instances": [
        {
          "attributes": {
            "id": "i-0abcd1234efgh5678",
            "ami": "ami-0c1a7f89451184c8b",
            "instance_type": "t3.micro",
            "public_ip": "18.130.42.101",
            "private_key": null
          }
        }
      ]
    }
  ]
}

Every attribute AWS returned when the instance was created is recorded here — not just the ones you set in your configuration. This is the mechanism behind terraform output referencing computed values like public_ip that you never specified: Terraform read them back from the actual API response and stored them in state.

State is not a cache for convenience. It is Terraform's only record of what it manages. Delete it, and Terraform has no memory that aws_instance.web was ever created — running apply again would try to create a second, duplicate instance, because as far as Terraform's next plan is concerned, nothing exists yet.

This has a few direct consequences worth internalizing now, before they cause a real problem later:

  • Never hand-edit resources that Terraform manages. If someone changes the instance type in the AWS console directly, Terraform's state no longer matches reality. The next plan will show a diff trying to revert that change — which is either exactly what you want (config is the source of truth) or a nasty surprise, depending on whether that console change was intentional.
  • State often contains sensitive data. Database passwords, private keys, connection strings passed as resource attributes — all of it can end up in plaintext inside terraform.tfstate. Treat that file with the same care as a secrets file, because it frequently is one.
  • Local state doesn't survive a team. By default, that state file sits on whoever's laptop ran apply first. The moment a second person needs to run Terraform against the same infrastructure, local state becomes actively dangerous — two people, two state files, both believing they have the authoritative picture. We cover the fix for this — remote backends and locking, plus how to actually inspect, repair, and migrate state files — in the fourth post in this series.

Detecting drift: when reality quietly stops matching state

terraform plan -refresh-only

Even without changing your configuration at all, infrastructure can drift — someone manually resizes an instance, an auto-remediation script changes a security group rule, a resource gets modified by something outside Terraform entirely. -refresh-only reconciles Terraform's state with the real, current state of your infrastructure without proposing any changes to reverse that drift — it just shows you what's actually different, so you can decide deliberately whether to accept the drift (by updating your configuration to match) or correct it (by running a normal apply to revert the drifted resource back to what your configuration says it should be). Running this periodically, especially on infrastructure other systems or people might touch outside Terraform, catches silent drift before it compounds into a much larger reconciliation problem.

Cleaning up

terraform destroy

destroy walks the state file in reverse dependency order — using the exact same DAG covered earlier in this post — and tears down everything Terraform currently tracks. On a learning environment, run this before you close the laptop — an idle t3.micro is cheap, but "cheap and forgotten for six months" is how AWS bills quietly creep up.

What to actually remember from this post

  • Terraform infers execution order from resource references, building a dependency graph automatically — you never declare ordering explicitly.
  • Providers translate HCL into API calls for a specific platform, and should always be version-pinned; the lock file pins the exact resolved version further, and belongs in version control.
  • plan before apply, every time — and pay attention to whether a change updates in place or destroys and recreates.
  • State is the ground truth, not a cache — protect it, don't hand-edit around it, and check for drift periodically rather than assuming your configuration and reality always agree.

Next in the series: Mastering Terraform Variables and Outputs, where this same configuration stops being hardcoded and starts being reusable.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.