Infrastructure as Code

Terraform: The Vocabulary of Declarative Infrastructure

Resource, provider, variable, output, module, state, plan, apply. Seven of these eight words exist in every declarative IaC tool; learn them as concepts and Terraform becomes the worked example rather than the subject.

The question this answers

Infrastructure question

What are the actual moving parts of a declarative IaC tool, and which of them are Terraform-specific rather than universal?

Application requirement

A new engineer must be able to read the repository that defines production and know, within an hour, what each block does, where a value comes from, and which file to change to add a queue.

What it provides

A small, stable vocabulary — resources with typed arguments, providers that translate them to API calls, variables and outputs as the interface between units of configuration — that transfers across tools and providers.

Application RequirementInfrastructure RequirementComputeNetworkStorageIdentityDeploymentScalingReliabilityObservabilitySecurityCostTrade-offs

Learn the concepts, then the syntax

Every declarative IaC tool has the same five ideas under different names. There is a resource: one thing that should exist, with typed arguments. There is a provider: the plugin that knows how to translate that resource into API calls against a specific platform, and that holds the credentials for it. There is an input (a variable) and an output, which together form the interface of a reusable unit. There is a module: a group of resources with an interface, so it can be instantiated more than once. And there is state, the tool's record of which real resource corresponds to which declared one.

Terraform names these resource, provider, variable, output, module and a state file. Pulumi names them classes, providers, constructor arguments, stack outputs, component resources and a state backend. CloudFormation names them Resources, an implicit provider, Parameters, Outputs, nested stacks and a stack. The vocabulary is portable; only the punctuation changes.

Two things *are* genuinely Terraform-specific and worth knowing as such: HCL is a configuration language rather than a general-purpose one, so loops and conditionals are deliberately limited and awkward by design; and providers are versioned plugins downloaded per configuration, which makes provider version pinning a first-class operational concern rather than an implementation detail. Since the licence change, OpenTofu is a compatible fork — everything in this lesson applies to both.

1terraform {
2 required_providers {
3 cloud = { source = "example/cloud", version = "~> 5.0" } # pin: an unpinned upgrade can propose a replace
4 }
5}
6
7provider "cloud" {
8 region = var.region # credentials come from the environment, never from here
9}
10
11variable "region" { type = string }
12variable "instance_count" { type = number, default = 2 }
13
14resource "cloud_instance" "api" {
15 count = var.instance_count # a fact about how many should exist
16 subnet_id = cloud_subnet.private_a.id # a reference: this is what creates the dependency edge
17 tags = { role = "api", env = var.environment }
18}
19
20output "api_private_ips" {
21 value = cloud_instance.api[*].private_ip
22}
The whole vocabulary in twenty lines. Read it as: who am I talking to, what should exist, what is configurable, what do I expose.

The provider is the part that holds the power

A provider is not a passive schema. It is a plugin that authenticates to a platform, translates resource blocks into API calls, and decides what "update this attribute" means — including whether an attribute can be changed in place or forces the resource to be destroyed and recreated. That decision lives in the provider, not in your configuration, and it is why the same-looking edit is harmless on one resource type and career-defining on another.

It also means the provider version is part of your infrastructure definition. A provider upgrade can change a default, add a computed attribute, or reclassify an argument as force-new. The failure looks like this: nobody changed the configuration, CI ran the weekly plan, and it now proposes to replace the production database because provider 5.2 computes an encryption attribute that 5.1 left null. Pin the version and upgrade deliberately, reading the changelog, with a plan you actually inspect.

Credentials belong to the environment the provider runs in, never in the configuration. The provider picks up a role, a workload identity or an environment variable. A configuration file containing an access key is both a secret leak and a portability failure — see Roles vs Static Keys.

ConceptTerraform / OpenTofuPulumiProvider-native stacksWhat it is actually for
A thing that should existresourceResource class instanceResources entryOne unit the tool creates, tracks and can destroy.
Platform adapterprovider (versioned plugin)Provider packageBuilt inTranslates declarations to API calls; holds credentials; decides what forces replacement.
InputvariableConfig / constructor argParametersThe interface: what a caller is allowed to change.
Published valueoutputStack outputOutputsWhat other configurations may consume without reaching inside.
Reusable groupmoduleComponent resourceNested stackA named boundary with an interface — see Modules: Reuse Without Hiding.
Reality mapState fileState backendManaged by the serviceWhich real resource is which declared one — see State: The File That Makes It Work and the File That Will Hurt You.
The vocabulary, mapped across tools. The concepts are the durable part.

How the pieces compose into a repository

A configuration directory is the unit of plan and apply, and it owns exactly one state. That is the single most important structural fact, because it means the directory boundary *is* the blast-radius boundary. Everything in one directory is planned together, locked together and can be destroyed together.

The usual shape that survives contact with a growing team: a small number of directories split by change frequency and blast radius — network and identity, shared data services, per-application resources — with modules as the reusable pieces inside them, and values passed between directories through published outputs read as remote state, never by copying ids. When the network configuration publishes its subnet ids as outputs and the application configuration reads them, the application can be destroyed and rebuilt a hundred times without ever touching the network.

Configuration directories, modules and the state each one owns
instantiated byoutputs: subnet idsoutputs: subnet idsinstantiated byauthenticated callsmodule: web-servicemodule: subnet-pairnetwork/ (own state)data/ (own state)app-payments/ (own state)provider pluginCloud API
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Key points

  • Resource, provider, variable, output, module and state are concepts every declarative tool has; only the syntax is Terraform's.
  • The provider is a versioned plugin that decides what an attribute change means — including whether it forces a destroy and recreate.
  • Pin provider versions. An unpinned upgrade turns an empty plan into a proposed replacement of resources nobody touched.
  • A configuration directory owns one state and is therefore the unit of planning, locking and blast radius.
  • Pass values between configurations through published outputs, not by pasting ids — pasted ids are what make a configuration apply cleanly in exactly one account.

The loop, answered

Every field is required, which is why no lesson here can recommend something without saying what it costs and what simpler thing to consider first.

How it works
  • The tool reads .tf files in the directory, evaluates variables and locals, and builds a resource graph from references.
  • It downloads and initialises the pinned provider plugins, which authenticate from the ambient environment.
  • Each resource block is matched to a state entry; the provider refreshes it against the live API to get current attributes.
  • The diff engine produces a per-resource action, consulting the provider schema for which attribute changes force replacement.
  • Apply walks the graph in dependency order, calls the provider, and writes the new attributes and identities into state.
What you still own
  • You own provider and module version pinning, and the deliberate upgrade cadence that goes with it.
  • You own the directory split. Getting it wrong is expensive to fix later because moving a resource between states is a manual, risky operation.
  • You own imports: resources created outside the tool must be imported, or the plan will propose to create a duplicate.
  • You own the credentials the provider picks up, and the fact that they are typically the broadest credentials in the system.
  • You own naming and tagging conventions, because they are the only thing that makes cost attribution possible later. See Cost per Service and the Attribution Problem.
How it fails
  • An unpinned provider upgrade proposes a replacement of a stateful resource on a run where the configuration did not change.
  • A resource created in the console and never imported: the plan proposes to create it again, and the apply fails on a name conflict — or worse, succeeds and creates a second one.
  • A count index shift: an element removed from the middle of a list, and every resource after it is destroyed and recreated. Use keyed instances for anything stateful.
  • Cyclic dependencies from a well-meaning reference, which the tool refuses to plan at all — usually the least harmful failure in this list.
  • A module upgrade whose interface changed, applied across twenty callers by an engineer who read only the first plan.
How it scales
  • Refresh time grows with resources per configuration directory, so the split by directory is also the scaling strategy.
  • Module reuse scales the team, not the machine: the constraint that binds is how many people can understand the interface, not how many resources exist.
  • Provider API rate limits bound wide graphs; parallelism above the provider's throttle just converts speed into intermittent failures.
Security
  • Provider credentials are ambient and broad. Prefer a short-lived assumed role in CI over a static key anywhere. See Roles vs Static Keys.
  • Never place credentials in a provider block. They end up in version control, in plan output and in state.
  • Third-party modules execute in your plan with your credentials — a module from an unreviewed registry is a supply-chain dependency, and should be pinned by version and reviewed like one.
  • Outputs marked sensitive are redacted in CLI output but stored in plaintext in state; redaction is a display feature, not a protection.
Cost shape
  • The tool costs nothing meaningful; provider API calls are free. The cost shape is engineering time and the resources the configuration creates.
  • Consistent tagging applied through the configuration is the mechanism that makes per-team and per-service cost attribution possible at all.
  • Modules that default to generous sizes propagate that default into every environment that instantiates them — a default is a bill multiplier.
What to watch
  • Plan output in code review, with the replace lines called out separately from the in-place updates.
  • Provider version drift across configuration directories, which is a leading indicator of the surprise-replacement failure.
  • Module version usage across the repository: an old version still in use somewhere is a migration that is not finished.
  • The signal that lies: apply complete. It confirms the API calls succeeded, not that the workload on top is serving traffic.
Simpler alternatives
  • The provider's own stack service, when you are committed to one provider — the state is managed for you, which removes an entire class of problem.
  • A general-purpose language IaC tool (Pulumi, CDK) when the configuration genuinely needs loops, types and testing that HCL makes awkward. The cost is that "anything is possible" also applies to complexity.
  • A managed platform with no infrastructure surface, for a small application. Nothing about a container and a database requires a resource graph.
  • For a single one-off resource, the console plus a note in the repository is more honest than a configuration directory that nobody will run twice.
What adopting this costs
  • Buys a portable, small vocabulary; costs a deliberately restricted configuration language that fights you on anything dynamic.
  • Buys provider abstraction; costs a versioned plugin whose upgrade can change semantics under an unchanged configuration.
  • Buys directory-level blast-radius isolation; costs cross-directory value passing and a split you have to get roughly right early.

What people believe, and what is true

Claim

Terraform is cloud-agnostic, so my configuration is portable.

Reality

The *tool* is agnostic; the resource blocks are provider-specific. Moving a configuration between clouds is a rewrite with a familiar syntax, not a migration.

Claim

The provider is just a schema.

Reality

It authenticates, calls the API and decides which attribute changes force a destroy-and-recreate. Its version is part of your infrastructure definition.

Claim

One repository, one configuration, one state — simple.

Reality

It is simple until the plan takes eight minutes and one engineer holds the lock. The directory boundary is a blast-radius decision made early.

Apply it