Terraform: a practical guide to infrastructure as code
Terraform is an infrastructure-as-code tool. You describe the target infrastructure in configuration files, and Terraform compares that description with real infrastructure, builds a plan, and then creates, updates, or deletes objects until the two match. The real job is not "writing cloud scripts." It is keeping an explicit model of infrastructure state.
Terraform can manage far more than basic IaaS objects. A Terraform configuration may include virtual machines, networks, DNS records, IAM bindings, managed databases, and even SaaS resources. The boundary is the provider model: if a provider can create, read, update, and delete a resource type, Terraform can manage it.
The CLI workflow has three moving parts:
- The Terraform CLI itself.
- Configuration files written in the Terraform language, which is based on HCL.
- Providers, which are plugins that talk to cloud or service APIs.
Terraform reads the configuration, builds an execution plan, and decides which objects must be created, changed, replaced, or removed. It also tracks dependencies between resources and applies changes in parallel where that is safe.
Install Terraform from the official downloads page and place the binary on $PATH.
Terraform supports -chdir=DIR to run commands against a different working directory. That is handy in scripts and monorepos.
Shell completion can be installed with terraform -install-autocomplete and removed with terraform -uninstall-autocomplete.
Many subcommands accept resource addresses. A few common forms are:
|
1 2 3 4 5 6 7 8 |
# resource_type.resource_name aws_instance.foo # indexed resource instance aws_instance.bar[1] # resource inside nested child modules module.foo.module.bar.aws_instance.baz |
The CLI configuration file path can be set with TF_CLI_CONFIG_FILE. On non-Windows systems, the default path is $HOME/.terraformrc. This file can configure plugin caching, credentials, and provider installation behavior.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
plugin_cache_dir = "$HOME/.terraform.d/plugin-cache" disable_checkpoint = true credentials "app.terraform.io" { token = "xxxxxx.atlasv1.zzzzzzzzzzzzz" } provider_installation { filesystem_mirror { path = "/usr/share/terraform/providers" include = ["example.com/*/*"] } direct { exclude = ["example.com/*/*"] } dev_overrides { "hashicorp.com/edu/hashicups-pf" = "$(go env GOBIN)" } } |
dev_overrides is mainly for provider development. It lets you test a local provider binary without going through the full registry and checksum flow.
terraform init prepares the working directory. Terraform commands are expected to run from a directory that contains Terraform configuration files. Initialization downloads providers and modules, sets up the backend, and creates local working data.
After initialization, the directory usually contains:
- .terraform/, which stores provider and module downloads.
- terraform.tfstate when the local backend is used.
- terraform.tfstate.d/ when multiple workspaces are used with the local backend.
Some changes require re-running initialization, especially provider version changes, module source changes, and backend configuration changes.
terraform get can download modules without doing the full set of init tasks. terraform init -upgrade upgrades providers and modules to newer versions that still satisfy the version constraints.
terraform validate checks whether the configuration is syntactically and structurally valid.
terraform plan shows the changes Terraform would like to make. It compares the desired state from configuration with the current state of the infrastructure, using both the state file and provider API reads.
Terraform's core execution loop is built around three commands: plan, apply, and destroy.
|
1 |
terraform plan -out=FILE |
A saved plan can later be passed to terraform apply.
- Destroy mode, enabled by -destroy, builds a plan that removes everything tracked by the current configuration.
- Refresh-only mode, enabled by -refresh-only, updates state and root outputs to match infrastructure changes made outside Terraform.
Use -var 'NAME=VALUE' to set input variables directly, and -var-file=FILENAME to load them from a file.
Use -parallelism=n to cap concurrency. The default is 10.
| Option | Meaning |
| -refresh=false | Skip the pre-plan refresh step. This can reduce remote API calls, but Terraform may miss drift introduced outside Terraform. |
| -replace=ADDRESS | Force Terraform to plan a replacement for a single resource instance, such as aws_instance.example[0]. |
| -target=ADDRESS | Limit planning to a specific resource and its dependencies. Useful for debugging, but easy to abuse. |
| -input=false | Disable interactive prompts for root input variables. This is standard in CI and batch execution. |
terraform apply executes the proposed changes. By default it runs an implicit plan first, though it can also execute a previously saved plan file.
The basic form is terraform apply [options] [plan file].
Use -auto-approve to skip manual approval.
Use -lock-timeout=DURATION to wait for a state lock before failing.
terraform destroy removes all infrastructure objects managed by the current configuration and workspace.
| Command | Meaning |
| console | Evaluate Terraform expressions interactively. |
| fmt | Format configuration files. |
| force-unlock | Remove a stale state lock. Use carefully, because unlocking while another process is still running can corrupt state. |
| graph | Generate a dependency graph of the configuration. |
| import | Attach an existing infrastructure object to a resource address in configuration. |
| login / logout | Manage credentials for remote services such as Terraform Cloud or a private module registry. |
| output | Show root module outputs. |
| providers | Show provider dependencies for the current module. |
| refresh | Refresh state to match remote infrastructure. |
| show | Display a saved plan or current state in human-readable form. |
| workspace | Manage and switch workspaces. |
taint marks a resource instance as not fully functional. That flag does not immediately change infrastructure, but the next plan will propose destroying and recreating the object.
untaint clears that status.
A Terraform configuration is built from blocks. The syntax looks like this:
|
1 2 3 |
<BLOCK TYPE> "<BLOCK LABEL>" "<BLOCK LABEL>" { <IDENTIFIER> = <EXPRESSION> } |
A block is a container, and its meaning depends on the block type. In a resource block, the two labels identify the resource type and local name.
Depending on block type, the number of labels may be zero, fixed, or variable. A block body may contain arguments or nested blocks. Top-level blocks are limited to a fixed set of Terraform language constructs.
|
1 2 3 |
resource "aws_vpc" "main" { cidr_block = var.base_cidr_block } |
An argument assigns a value to a name. The available arguments and their types depend on context, usually the resource type or block type.
Identifiers are used for argument names, block type names, and many Terraform object names. They may contain letters, digits, -, and _, but cannot start with a digit.
Single-line comments can start with # or //. Multi-line comments use /* ... */.
| Type | Meaning |
| string | Unicode text, for example "hello". |
| number | Numeric value, for example 6.02. |
| bool | true or false. |
| list / tuple | Ordered collections, for example ["us-west-1a", "us-west-1c"]. |
| map / object | Key-value structures, for example { name = "Mabel", age = 52 }. |
null represents the null value.
Terraform strings support standard escapes such as \n, \r, \t, \", \\, \uNNNN, and \UNNNNNNNN.
|
1 2 3 4 5 6 |
block { value = <<EOT hello world EOT } |
Indented heredoc is also supported:
|
1 2 3 4 5 6 |
block { value = <<-EOT hello world EOT } |
Terraform can render JSON or YAML from native values with helper functions such as jsonencode:
|
1 2 3 4 |
example = jsonencode({ a = 1 b = "hello" }) |
Terraform supports interpolation with ${ ... } and template directives with %{ ... }.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# expression interpolation "Hello, ${var.name}!" # conditional template "Hello, %{ if var.name != "" }${var.name}%{ else }unnamed%{ endif }!" # loop template <<EOT %{ for ip in aws_instance.example.*.private_ip } server ${ip} %{ endfor } EOT |
Whitespace trimming uses ~ inside template directives.
Terraform expressions can reference values from several sources:
- <RESOURCE TYPE>.<NAME> for managed resources.
- var.<NAME> for input variables.
- local.<NAME> for locals.
- module.<MODULE NAME> for child module outputs.
- data.<DATA TYPE>.<NAME> for data resources.
- path.module, path.root, and path.cwd for filesystem paths.
- terraform.workspace for the current workspace name.
Special values also appear in certain contexts, including count.index, each.key, each.value, and self.
Terraform supports logical operators such as !, &&, and ||; arithmetic operators such as *, /, %, +, and -; and the usual comparison operators.
|
1 2 3 4 |
<FUNCTION NAME>(<ARGUMENT 1>, <ARGUMENT 2>) # argument expansion min([55, 2453, 2]...) |
|
1 2 3 |
condition ? true_val : false_val var.a != "" ? var.a : "default-a" |
A for expression transforms one complex value into another. Each input element may contribute zero or one output element.
|
1 2 3 4 5 |
[for s in var.list : upper(s)] [for k, v in var.map : length(k) + length(v)] { for s in var.list : s => upper(s) } |
You can also filter values with an if clause:
|
1 |
[for s in var.list : upper(s) if s != ""] |
Grouping mode is enabled by adding ... at the end of the value expression:
|
1 2 3 4 5 |
locals { users_by_role = { for name, user in var.users : user.role => name... } } |
Expressions can assign argument values, but they cannot directly repeat or conditionally emit nested blocks. That is where dynamic blocks come in.
|
1 2 3 4 5 6 7 8 9 10 |
resource "aws_elastic_beanstalk_environment" "example" { dynamic "setting" { for_each = var.settings content { namespace = setting.value["namespace"] name = setting.value["name"] value = setting.value["value"] } } } |
dynamic can generate nested blocks inside resources, data sources, providers, and provisioners. It cannot generate meta-argument blocks such as lifecycle.
Splat expressions are a concise alternative to some for expressions:
|
1 2 3 4 5 |
[for o in var.list : o.id] var.list[*].id [for o in var.list : o.interfaces[0].name] var.list[*].interfaces[0].name |
Splat syntax works with list-like collections, not maps or objects. It can also turn a single optional value into a list-like expression in some contexts:
|
1 |
for_each = var.website[*] |
Module and provider authors can use type constraints to validate user input. Terraform's type system is stronger than it first appears. You can constrain not only the outer type, but also the shape and element types inside it.
|
1 2 3 4 5 6 7 |
list(string) list(number) list(any) object({ name = string, age = number }) tuple([string, number, bool]) |
Terraform also performs automatic conversions between similar complex types, such as object and map, or tuple and list, when the values fit the required shape. That flexibility is convenient, but it also means module authors should think carefully about how strict they want input constraints to be.
any is not really a type. It is a placeholder that Terraform resolves to a concrete type during type-checking. For example, a value such as ["a", "b", "c"] can satisfy list(any), and Terraform will infer a more specific list element type behind the scenes.
|
1 2 3 4 5 6 |
variable "with_optional_attribute" { type = object({ a = string b = optional(string) }) } |
Version constraints appear when selecting modules, providers, or the Terraform CLI version itself:
|
1 2 3 4 5 6 |
version = ">= 1.2.0, < 2.0.0" = != > >= < <= ~> |
~> allows changes to the rightmost specified version component.
A resource block declares the desired shape of a real infrastructure object:
|
1 2 3 |
resource "resource_type" "local_name" { # arguments... } |
The resource type decides which arguments exist. The local name only matters inside the current module. Together, the type and local name form the module-local identity of the resource.
When Terraform creates a new resource, it stores the remote object's identifier in state. On later runs, Terraform compares the real object with the configuration and decides whether to update it in place, replace it, or leave it alone.
When a configuration is applied, Terraform generally does four things:
- Create resources that exist in configuration but not in state.
- Destroy resources that exist in state but no longer exist in configuration.
- Update resources whose arguments changed and support in-place changes.
- Replace resources whose arguments changed but cannot be updated in place.
That last case depends heavily on provider behavior and the underlying API. Terraform decides the graph; the provider decides what each API operation can actually do.
Within the same module, resource attributes are accessed as <RESOURCE TYPE>.<NAME>.<ATTRIBUTE>.
Besides user-supplied arguments, resources also expose read-only attributes that come back from the provider API, such as generated IDs.
Terraform infers most dependencies from expressions. If one resource argument references another resource, Terraform treats that as a dependency edge in the graph.
For dependencies that cannot be inferred from expressions, use the depends_on meta-argument.
Some resource types do not represent remote infrastructure at all. They only store data in Terraform state. These local-only resources are often used for intermediate values such as generated random IDs or local key material.
Every resource type belongs to a provider. A provider is a Terraform plugin that implements one or more resource types and data source types.
A module needs providers for every resource it uses, and provider configuration is usually supplied by the root module. Providers can also expose multiple configurations, often to target different regions or accounts.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
provider "google" { region = "us-central1" } provider "google" { alias = "europe" region = "europe-west1" } resource "google_compute_instance" "example" { provider = google.europe } |
Resources implicitly depend on their selected provider configuration, so Terraform will not try to create the resource before the provider is ready.
depends_on handles dependencies that expression analysis cannot see. It should be used sparingly.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
resource "aws_iam_role" "example" { name = "example" } resource "aws_iam_role_policy" "example" { role = aws_iam_role.example.name } resource "aws_instance" "example" { iam_instance_profile = aws_iam_role.example.name depends_on = [ aws_iam_role_policy.example, ] } |
count creates several similar resource instances from one block:
|
1 2 3 4 5 6 7 8 9 10 |
resource "aws_instance" "server" { count = 4 ami = "ami-a1b2c3d4" instance_type = "t2.micro" tags = { Name = "Server ${count.index}" } } |
Instances are referenced with index syntax such as aws_instance.server[0].
for_each is more flexible than count when instances differ in meaningful ways. It accepts a map or a set(string).
|
1 2 3 4 5 6 7 8 9 |
resource "azurerm_resource_group" "rg" { for_each = { a_group = "eastus" another_group = "westus2" } name = each.key location = each.value } |
Resources created by for_each are referenced with key syntax such as azurerm_resource_group.rg["a_group"].
The keys must be known before apply, cannot come from impure functions such as uuid or timestamp, and cannot be sensitive values.
You can also chain for_each from one resource to another:
|
1 2 3 4 5 6 7 8 9 |
resource "aws_vpc" "example" { for_each = var.vpcs cidr_block = each.value.cidr_block } resource "aws_internet_gateway" "example" { for_each = aws_vpc.example vpc_id = each.value.id } |
The lifecycle block customizes replacement and update behavior:
|
1 2 3 4 5 |
resource "azurerm_resource_group" "example" { lifecycle { create_before_destroy = true } } |
| Argument | Meaning |
| create_before_destroy | Create the replacement first, then delete the old object. |
| prevent_destroy | Fail if the plan would delete the resource. |
| ignore_changes | Ignore selected attribute differences when deciding whether an update is needed. The special value all suppresses all updates. |
Some resource types provide a nested timeouts block:
|
1 2 3 4 5 6 7 |
resource /* ... */ { timeouts { create = "60m" update = "30m" delete = "2h" } } |
Provisioners are the escape hatch for actions that do not fit Terraform's declarative model. Use them reluctantly. They add uncertainty and sit outside the normal planning model.
Terraform cannot reason very well about provisioner side effects. Provisioners also tend to need direct network access, credentials, and timing assumptions that make runs less predictable.
Provisioners use self to refer to the parent resource. They also support when and on_failure:
|
1 2 3 4 5 6 |
resource "aws_instance" "web" { provisioner "local-exec" { when = destroy command = "echo 'Destroy-time provisioner'" } } |
If a create-time provisioner fails, Terraform marks the resource tainted so the next apply can replace it.
Many provisioners need SSH or WinRM. Connection details can be declared at the resource level or on a specific provisioner:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
provisioner "file" { connection { type = "ssh" user = "root" password = var.root_password host = var.host } } provisioner "file" { connection { type = "winrm" user = "Administrator" password = var.admin_password host = var.host } } |
null_resource exists for provisioner-driven workflows that are not tied to a real managed resource.
|
1 2 3 4 5 6 7 8 9 10 11 |
resource "null_resource" "cluster" { triggers = { cluster_instance_ids = join(",", aws_instance.cluster.*.id) } provisioner "remote-exec" { inline = [ "bootstrap-cluster.sh ${join(" ", aws_instance.cluster.*.private_ip)}", ] } } |
The common built-in provisioners are:
| Provisioner | Meaning |
| file | Copy files or directories from the machine running Terraform to the target resource. |
| local-exec | Run a local command after a resource action. |
| remote-exec | Connect to the remote resource and run commands there. |
A data source, declared with a data block, reads information from an external system and exposes the result to the configuration. It is still provider-backed, but it only reads.
|
1 2 3 4 5 6 7 8 9 |
data "aws_ami" "example" { most_recent = true owners = ["self"] tags = { Name = "app-server" Tested = "true" } } |
If the query arguments are known during planning, Terraform reads the data source during refresh. If those arguments depend on values that will only exist after apply, Terraform delays the read until apply time.
Data sources support the same dependency patterns and most of the same meta-arguments as managed resources.
Modules in Terraform behave a bit like functions. Input variables are the parameters, outputs are the return values, and locals are internal named expressions.
Input variables parameterize a module so it can be reused in different configurations. Root module variables can be set from the CLI or variable files. Child module variables must be passed through the corresponding module block.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
variable "image_id" { type = string description = "" validation { condition = bool-expr error_message = "" } sensitive = false } variable "availability_zone_names" { type = list(string) default = ["us-west-1a"] } |
Variable values can come from -var, -var-file, environment variables, or automatically loaded files such as terraform.tfvars.
Locals are named expressions used to simplify or normalize configuration logic:
|
1 2 3 4 5 6 |
locals { common_tags = { Project = "demo" Owner = "infra" } } |
Locals can reference other locals as long as there is no dependency cycle.
Outputs expose values from a module to its caller or to the CLI:
|
1 2 3 |
output "vpc_id" { value = aws_vpc.main.id } |
Terraform makes more sense once you treat it as a graph engine wrapped around provider APIs. Configuration declares vertices and edges. State records which remote objects correspond to which addresses. Providers translate graph operations into API calls.
Most Terraform work is not about memorizing syntax. It is about knowing which values are known at plan time, where dependencies come from, what the provider can update in place, and when a resource has to be replaced. Once those four things are clear, the language stops feeling mysterious.
Leave a Reply