Infrastructure as Code and Terraform Architecture
Infrastructure as Code, or IaC, means the desired shape of infrastructure is written in files that can be reviewed, versioned, tested, and replayed. Terraform is one IaC engine. Its outcome is not merely a script that calls cloud APIs. Terraform reads configuration, consults state, asks provider plugins how remote systems behave, builds a dependency graph, and creates a plan that says which objects it will create, update, replace, or destroy.
In this first Terraform Foundations lesson, the goal is to understand that architecture before you memorize commands. After the lesson, you should be able to look at a Terraform change and explain where the desired configuration lives, where Terraform records the last known real objects, which provider owns each operation, why resources run in a particular order, and what evidence a plan gives you before anything changes.
What Terraform Adds to IaC
A shell script usually describes steps: create a network, then create a subnet, then attach a route. Terraform configuration describes objects and relationships: a network should exist, a subnet should refer to that network, and an output should expose the resulting identifier. Terraform compares that desired model with the current state and produces an action plan. This distinction matters because repeated applies should converge on the same result instead of blindly repeating operations.
Terraform also gives infrastructure a stable address. A block such as terraform_data.network is not just text in a file. It is an address that Terraform binds to an instance recorded in state. If the address changes, Terraform may treat the old object as removed and the new address as a separate object unless you move or import state deliberately. That address-to-object binding is the center of Terraform architecture.
Internal Architecture
The Terraform CLI is the local orchestration engine. It parses HCL configuration, loads modules, resolves variables, downloads provider plugins, initializes a backend, refreshes state when requested, asks providers to validate and plan resource changes, constructs a graph, and walks that graph during apply. The CLI itself does not know how to create an AWS bucket, a Kubernetes namespace, or a GitHub repository. Providers supply schemas and operations for specific APIs.
A provider is a separate plugin process. During planning, Terraform sends the proposed configuration to the provider. The provider checks arguments against its schema, reads existing remote objects when it has an object identity, and returns planned attribute values. Some values are known immediately, such as a literal name. Others are unknown until apply, such as an id assigned by a remote API. Terraform displays those as values known after apply.
State is Terraform’s record of managed objects. It stores resource addresses, provider bindings, object identifiers, dependency metadata, and attribute values. State can contain secrets because providers often return sensitive fields. A local state file is fine for a private lab, but team use normally needs a remote backend with locking, encryption, access control, and backup. Without locking, two applies can both believe they own the same previous state and race each other.
The dependency graph controls order. Terraform creates graph nodes for resources, data sources, outputs, provider configurations, and some expressions. References create edges. If a subnet uses terraform_data.network.id, the subnet depends on the network. Terraform can run unrelated nodes in parallel, but it must respect edges. Explicit depends_on is available, but overusing it hides the real data relationship. Prefer references when one object truly consumes another object’s attribute.
Configuration Anatomy
Terraform configuration uses HCL blocks, arguments, and expressions. A block has a type and labels, for example resource "terraform_data" "network". Arguments assign values inside a block. Expressions combine literals, variables, functions, references, conditionals, and collection operations. Input variables define a module interface. Locals name derived values. Outputs expose selected results. A module is simply a directory of Terraform configuration with an input and output contract.
The usual workflow is terraform init, terraform fmt, terraform validate, terraform plan, and terraform apply. Initialization prepares the working directory and backend. Formatting normalizes style. Validation checks syntax and provider schema compatibility. Planning compares desired configuration with state and remote reality. Applying executes the reviewed plan and writes updated state if successful.
Example 1: Values Before Resources
This first example has no managed infrastructure. It shows Terraform’s expression layer: variables, validation, locals, and outputs. Save it as main.tf and run terraform plan -var environment=stage.
variable "environment" {
type = string
description = "Deployment environment name."
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "environment must be dev, stage, or prod."
}
}
locals {
name_prefix = "billing-${var.environment}"
common_tags = {
ManagedBy = "Terraform"
Environment = var.environment
}
}
output "queue_name" {
value = "${local.name_prefix}-events"
}
output "tags" {
value = local.common_tags
}
The variable validation rejects unexpected environments before a plan is useful. With environment=stage, Terraform can determine both outputs during planning: queue_name is billing-stage-events, and tags contains ManagedBy = Terraform and Environment = stage. With environment=test, the expected behavior is a validation error saying the environment must be dev, stage, or prod.
Example 2: Graph Edges and Unknown Values
The second example uses the built-in terraform_data resource so you can inspect Terraform behavior without cloud credentials. The subnet input references the network id. That reference creates a graph edge.
variable "environment" {
type = string
default = "dev"
}
resource "terraform_data" "network" {
input = {
cidr = "10.20.0.0/16"
name = "app-${var.environment}-network"
}
}
resource "terraform_data" "subnet" {
input = {
cidr = "10.20.10.0/24"
network_id = terraform_data.network.id
}
}
output "subnet_depends_on_network" {
value = terraform_data.subnet.output.network_id == terraform_data.network.id
}
From an empty state, the plan proposes two resources to add. Terraform must create terraform_data.network before terraform_data.subnet because the subnet input contains terraform_data.network.id. Before apply, the id is unknown. After apply, the output subnet_depends_on_network becomes true because the subnet stored the network id produced by the first resource. If you later change only the subnet CIDR, Terraform plans an update to the subnet object, not the network, because the address and network input remain stable.
Example 3: Remote State and Locking
The third example is a backend configuration fragment, so it is not a complete module by itself. It shows the architectural choice that separates a single-user experiment from a team workflow.
terraform {
backend "s3" {
bucket = "example-company-terraform-state"
key = "platform/dev/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "example-company-terraform-locks"
encrypt = true
}
}
With an S3 backend, Terraform stores state at a configured object key instead of only in the working directory. The lock table prevents two runs from writing state at the same time. The bucket, key, and table names are examples; create them through a bootstrap process outside the workspace that uses them. The deterministic behavior to expect is that a second apply against the same state waits or fails with a lock message while the first run holds the lock.
Design Choices and Trade-offs
Local state is simple and transparent, but it is fragile for teams because there is no shared lock or central backup. Remote state adds operational setup, but it gives collaboration, history, and controlled access. One large state file makes cross-resource references easy, yet it increases blast radius and slows planning. Many small states reduce blast radius, but they force you to pass outputs between stacks carefully and avoid circular ownership.
Modules improve reuse when they encode a real platform standard, such as a network shape or service deployment contract. Premature modules can make simple resources harder to inspect and can hide dangerous defaults. Provider version constraints should be pinned enough to avoid surprise behavior changes but maintained often enough to receive bug fixes. A saved plan in automation improves review fidelity because apply can execute the exact plan that was approved.
Terraform is declarative, but not magically reversible. Some remote APIs replace objects for changes that look small. Some names are globally unique and cannot be immediately reused. Some resources contain data that outlives the Terraform object. Review the plan symbols carefully: create, update, destroy, and replace have different operational consequences.
Failure Modes and Troubleshooting
Invalid input. The symptom is a validation error before Terraform reaches provider planning. The cause is usually a variable value outside the module contract. Diagnose it by reading the variable block and running terraform validate plus a plan with the exact variable file. Correct it by changing the input or widening validation only when the module genuinely supports the new case.
Provider or backend initialization failure. The symptom appears during terraform init: Terraform cannot download a provider, authenticate to a backend, or read backend configuration. The cause may be network access, an unavailable registry, wrong credentials, or a backend bucket that has not been bootstrapped. Diagnose by separating provider installation from backend authentication and checking the effective environment variables. Correct the credentials or backend resources before planning.
Drift. The symptom is a plan that changes infrastructure even though configuration did not change. The cause is a remote object modified outside Terraform or a provider reading a field whose default changed remotely. Diagnose with terraform plan, inspect the changed attributes, and compare remote audit logs. Correct by reverting the manual change, updating configuration to match the intentional change, or importing a separately created object into state.
State lock contention. The symptom is a message that Terraform cannot acquire the state lock. The cause is another active run or a stale lock left by an interrupted process. Diagnose by identifying the lock owner and checking whether that run is still active. Correct by waiting for the active run, or by using the backend’s documented unlock procedure only after proving no apply is still running.
Security, Reliability, and Performance Implications
State protection is a security requirement. Treat state as sensitive, restrict who can read it, encrypt it at rest, and avoid placing long-lived secrets in resource arguments when the provider offers a safer reference mechanism. Credentials used by Terraform should be scoped to the workspace’s responsibilities. A plan produced with administrator credentials can hide the fact that normal automation permissions are too broad.
Reliability comes from small, reviewable changes and clear ownership. Avoid mixing unrelated network, database, and application changes in one apply. Use lifecycle rules carefully. prevent_destroy can stop accidental deletion of critical resources, but it can also block legitimate replacement until an operator makes a conscious change. Performance depends on provider API calls and graph size. Large states, slow data sources, and unnecessary dependencies make plans slower and reduce parallelism.
Hands-on Lab: Inspect a Local Terraform Graph
Prerequisites: a shell, Terraform installed, and an empty temporary directory. This lab uses only the built-in terraform_data resource, so no cloud account is required.
- Create a new directory and place this configuration in
main.tf.
resource "terraform_data" "service_contract" {
input = {
service = "checkout"
environment = "dev"
replicas = 2
}
}
output "contract" {
value = terraform_data.service_contract.output
}
- Run
terraform init. Verification: the command completes and creates.terraform.lock.hcl. - Run
terraform fmtandterraform validate. Verification: validation reports that the configuration is valid. - Run
terraform plan -out=tfplan. Verification: the plan says it will add one resource and thecontractoutput will be known after apply. - Run
terraform apply tfplan. Verification: Terraform reports one resource added, andterraform output contractshows servicecheckout, environmentdev, and replicas2. - Change
replicasfrom2to3and runterraform plan. Verification: Terraform proposes an update toterraform_data.service_contract, not a second resource with a new address. - Cleanup: run
terraform destroyand approve the prompt. Verification: Terraform reports one resource destroyed, and the state no longer tracks the resource.
Assessment Exercises
- A teammate renames
terraform_data.networktoterraform_data.vpcwithout changing the remote object concept. What will Terraform likely plan, and what state operation would preserve identity? - Why does a reference such as
terraform_data.subnet.input.network_id = terraform_data.network.idaffect apply order even without explicitdepends_on? - You see a plan after no code changes. List three possible causes and the diagnostic evidence you would gather before applying.
- Split a monolithic state for network and application resources. What improves, what becomes harder, and how should outputs cross the new boundary?
- Design a review rule for plans that include replacement of a database, queue, or storage bucket. What evidence should block approval?
Summary
Terraform turns IaC from a collection of provisioning commands into a reconciliation system built from configuration, provider schemas, state, and a dependency graph. The practical skill is to preserve resource identity, protect state, read plans skeptically, and choose module and backend boundaries that match real ownership. When you understand those internals, later Terraform topics such as variables, modules, providers, workspaces, imports, and drift management fit into a coherent architecture.
