Terraform: Infrastructure as Code From Scratch

Providers, state, remote backends, modules, the plan and apply loop, drift, imports, and a multi-environment layout that survives a second engineer. Working HCL and real commands.

main.tf tfstate plan + 4 create ~ 1 update - 0 destroy APPLY
3
Commands do most of the work
1.5
Version that added import blocks
1.10
Version that added S3 native locking
0
Times you edit state by hand

Terraform is an open-source tool that creates, changes, and destroys infrastructure from configuration files. You describe what you want in HashiCorp Configuration Language: a VPC, three subnets, a database, an IAM role. Terraform compares that description against a record of what it built last time, called the state file, and against what the provider API says exists right now. It then prints a plan listing every create, update, and destroy needed to close the gap. You read the plan, you approve it, and Terraform makes the API calls in dependency order.

That loop is the whole idea. terraform init downloads providers and connects the backend, terraform plan shows the diff, terraform apply executes it. Everything else below exists to make that loop safe once a second engineer, a CI pipeline, and real production traffic are involved.

init
Downloads providers, wires the backend, writes the lock file.
plan
Prints the exact diff. Save it to a file so the apply cannot drift from the review.
apply
Calls the provider APIs in dependency order, then updates state to match.

Key Takeaways

Every block below is real HCL or a real command. The examples use AWS, but the concepts move directly to Google Cloud, Azure, Cloudflare, or any other provider in the public registry.

01

Install Terraform and Apply Your First Configuration

Install the CLI, write a provider block and one resource, then run init, plan, and apply. A first working configuration is about fifteen lines.

On macOS, install through the HashiCorp tap. On Debian or Ubuntu, add the HashiCorp apt repository. If you need several Terraform versions on one machine, a version manager such as tfenv or mise is worth the setup.

install and verify
# macOS brew tap hashicorp/tap brew install hashicorp/tap/terraform # Verify. Anything on the 1.x line works with this guide. terraform -version # Optional: shell autocompletion terraform -install-autocomplete

Create an empty directory and one file. The terraform block pins CLI and provider versions, the provider block sets region and credentials, and each resource block declares one object you want to exist.

main.tf
terraform { required_version = ">= 1.9" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" # pin the major you start on } } } provider "aws" { region = "us-east-1" } resource "aws_s3_bucket" "artifacts" { bucket = "acme-artifacts-prod-7f3a" tags = { Environment = "prod" ManagedBy = "terraform" } } resource "aws_s3_bucket_versioning" "artifacts" { bucket = aws_s3_bucket.artifacts.id versioning_configuration { status = "Enabled" } }

The second resource references aws_s3_bucket.artifacts.id, and that reference is what tells Terraform the bucket must exist first. You never write ordering by hand; the graph comes from the references. depends_on is the escape hatch for dependencies the arguments do not express, and needing it often means the configuration can be simplified.

Credentials come from the usual AWS sources: aws configure, an AWS_PROFILE environment variable, an instance role, or an OIDC token in CI. Do not put keys in the provider block.

the first loop
terraform init # download the AWS provider, write .terraform.lock.hcl terraform fmt # canonical formatting, in place terraform validate # types and syntax, no API calls terraform plan # read the diff before it happens terraform apply # type yes at the prompt # Tear it back down when you are done experimenting terraform destroy
02

Providers, Version Pinning, and the Lock File

A provider is a plugin that turns HCL into API calls. Pin its major version, commit .terraform.lock.hcl, and use alias when one configuration touches two regions or accounts.

Terraform itself knows nothing about S3 buckets or Kubernetes namespaces; providers do. The public registry carries providers for the major clouds and for most SaaS platforms with an API, and a single configuration can use several at once. That is how a load balancer, its DNS record, and its monitoring alert end up in one plan.

Version constraints matter more than people expect. ~> 6.0 means at least 6.0 and less than 7.0, so patch and minor upgrades arrive automatically while a breaking major release does not. Without a constraint, a colleague running init next week gets a different provider than you did and the plan will not match.

The lock file records exact provider versions and checksums. Commit it. It is the difference between reproducible plans and mysterious diffs. Refresh it deliberately with terraform init -upgrade and review that change like any other dependency bump.

lock file and multi-region providers
# Record hashes for every platform your team and CI run on, # otherwise Linux CI rejects a lock file written on a Mac. terraform providers lock \ -platform=darwin_arm64 \ -platform=linux_amd64 # --- second region via an aliased provider --- provider "aws" { alias = "west" region = "us-west-2" } resource "aws_s3_bucket" "dr" { provider = aws.west bucket = "acme-artifacts-dr-7f3a" }
03

The Plan and Apply Loop, Command by Command

Save the plan to a file and apply that file. It removes the window in which the world changes between review and execution.

A plan is not a loose preview. It is a concrete, ordered set of API operations, and Terraform can serialize it. Applying a saved plan skips the confirmation prompt, because you confirmed by reviewing it, and guarantees the applied change is the reviewed change. Read the symbols the way you read a diff.

SymbolMeaningWhat to check
+CreateNames and identifiers you cannot change later
~Update in placeWhether the update causes downtime on that service
-DestroyAnything stateful. Stop here if you did not expect it.
-/+ReplaceWhich argument forces replacement, printed in the plan
<=Read data sourceNothing changes; this is a lookup
the loop as CI runs it
terraform init -input=false terraform fmt -check -recursive # fail the build on unformatted files terraform validate terraform plan -input=false -lock-timeout=5m -out=tfplan terraform show -no-color tfplan # human-readable, for the PR comment terraform show -json tfplan > plan.json # machine-readable, for policy checks terraform apply -input=false tfplan # applies exactly what was reviewed # Drift job: 0 = no changes, 1 = error, 2 = changes pending terraform plan -detailed-exitcode

Two commands shorten debugging. terraform console is a REPL against your current state and variables, the fastest way to check what an expression returns. TF_LOG=DEBUG terraform plan prints the provider's HTTP calls when a resource behaves in a way the documentation does not explain.

04

State: The File You Never Edit by Hand

State maps each address in your configuration to a real object ID, caches attribute values, and records dependencies. It exists because no cloud API can answer "which of these did Terraform create?"

Open terraform.tfstate once so it stops being mysterious. It is JSON: a serial number, a lineage identifier, and a list of resources with addresses, provider, and full attribute values. That last part matters. State contains secrets in plaintext. A generated database password, a private key, a token returned by an API, all of it lands in state whether or not the argument is marked sensitive. Setting sensitive = true hides a value from CLI output and nothing more.

So: store state in a bucket with encryption and tight IAM, never commit it to git, rotate every credential in it if that has already happened, and treat read access to state as equivalent to read access to your secrets.

Because state is JSON it looks editable. It is not. Use the subcommands, and prefer configuration-level refactoring blocks over CLI surgery, since those are reviewable in a pull request and run for everyone.

state operations
terraform state list # every managed address terraform state show aws_s3_bucket.artifacts # one resource, fully terraform state rm aws_s3_bucket.legacy # stop managing, do NOT destroy terraform apply -refresh-only # sync state to reality, change nothing # --- prefer these blocks over 'terraform state mv' --- moved { from = aws_s3_bucket.artifacts to = module.storage.aws_s3_bucket.artifacts } # Terraform 1.7+: drop a resource from state, leave it running removed { from = aws_s3_bucket.legacy lifecycle { destroy = false } }
05

Remote Backends: S3, Locking, and Working as a Team

Local state fails the moment a second person or a CI job runs. A remote backend puts state in shared storage and takes a lock during operations so two applies cannot interleave.

The S3 backend is the common choice on AWS. Since Terraform 1.10 it can lock using S3 conditional writes through use_lockfile, which removes the DynamoDB table earlier versions required. On an older CLI, set dynamodb_table instead and create that table with a LockID string partition key.

backend.tf
terraform { backend "s3" { bucket = "acme-tfstate" key = "prod/network/terraform.tfstate" region = "us-east-1" encrypt = true use_lockfile = true # Terraform 1.10+; replaces dynamodb_table } }

Set the state bucket up properly once: versioning on so a bad write can be rolled back, public access blocked, encryption enabled, and a policy that only your Terraform roles can read. There is a bootstrapping order problem, since the bucket must exist before it can hold the state that manages it. The usual answers are a tiny separate configuration with local state, or creating the bucket by hand and importing it later.

Backend blocks cannot use variables or interpolation, so to keep one configuration and swap environments, pass the rest at init time.

partial backend config per environment
# envs/prod.s3.tfbackend bucket = "acme-tfstate" key = "prod/network/terraform.tfstate" region = "us-east-1" # Then, at the CLI: terraform init -backend-config=envs/prod.s3.tfbackend # Moving from local to remote state, or between backends: terraform init -migrate-state

If you would rather not run backends yourself, HCP Terraform (formerly Terraform Cloud) stores state and runs plans remotely, and you point at it with a cloud block. Azure and Google Cloud have first-party backends. The requirement is the same everywhere: shared storage plus locking.

06

Variables, Outputs, and Locals

Variables are typed inputs with optional validation, locals are named expressions computed once, and outputs are the values a module publishes to its caller or to you at the CLI.

variables.tf
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." } } variable "subnet_cidrs" { type = list(string) default = ["10.0.1.0/24", "10.0.2.0/24"] } variable "db_password" { type = string sensitive = true # hides CLI output only, NOT state } locals { name_prefix = "acme-${var.environment}" common_tags = { Environment = var.environment ManagedBy = "terraform" } } output "bucket_arn" { value = aws_s3_bucket.artifacts.arn description = "ARN other stacks reference" }

Values resolve in a fixed order, later sources winning: defaults, then TF_VAR_ environment variables, then terraform.tfvars, then any *.auto.tfvars alphabetically, then -var-file, then -var. Knowing that order resolves most "why is it still using the old value" confusion.

Keep secrets out of .tfvars files, which get committed by accident more often than anything else in a Terraform repository. Pass them as TF_VAR_db_password from your CI secret store, or read them at plan time from a secrets manager data source. Add *.tfvars, *.tfstate*, and .terraform/ to .gitignore on day one.

07

Modules: Writing One and Calling It

A module is a directory of .tf files with inputs and outputs. The directory you run Terraform in is the root module; everything it calls is a child module.

Factor code into a module when the same shape of infrastructure appears in more than one place, or when a boundary makes ownership clearer. Resist wrapping a single resource in a module that exposes one variable per argument, which adds indirection and buys nothing. A good module owns one coherent thing and exposes a small number of inputs.

calling a module
# Layout # modules/service/{main.tf,variables.tf,outputs.tf} # envs/prod/main.tf module "api" { source = "../../modules/service" name = "api" environment = var.environment desired_count = 3 } # Registry module: pin the major you tested against module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0" name = "acme-prod" cidr = "10.0.0.0/16" } # Git source, pinned to a tag module "logging" { source = "git::https://github.com/acme/tf-modules.git//logging?ref=v1.4.0" }

One rule prevents more incidents than anything else here: use for_each rather than count whenever the collection can change in the middle. With count, resources are addressed by index, so deleting the first of five renumbers the rest and Terraform proposes destroying and recreating all of them. With for_each, each resource has a stable key and removing one touches only that one.

for_each with stable keys
resource "aws_s3_bucket" "logs" { for_each = toset(["app", "audit", "billing"]) bucket = "${local.name_prefix}-logs-${each.key}" tags = local.common_tags } # Address: aws_s3_bucket.logs["audit"] (key, not index) # Removing "app" leaves "audit" and "billing" untouched.
08

Multi-Environment Patterns That Hold Up

A directory per environment is the safer default. CLI workspaces are for identical copies of one stack, such as per region or per tenant, not for environments that genuinely differ.

With a directory per environment, each has its own backend key, state, and credentials, and the differences between dev and prod live in files rather than in conditionals. Plans stay short, and a mistake in dev cannot reach prod state.

repository layout
infra/ modules/ network/ service/ envs/ dev/ main.tf # calls modules, dev-sized inputs backend.tf # key = dev/terraform.tfstate terraform.tfvars stage/ prod/ main.tf backend.tf # key = prod/terraform.tfstate terraform.tfvars

Workspaces solve a different problem. terraform workspace new eu-west gives you a second state file under the same configuration and backend, with the name exposed as terraform.workspace. That fits stamping one stack into many regions. It goes wrong for dev and prod, because the configuration fills with expressions like terraform.workspace == "prod" ? 3 : 1 and the environments quietly stop being comparable.

On larger systems, split further by lifecycle: network in one state, data stores in another, applications in a third. Applications change daily and networks change quarterly, and separate states keep a routine deploy from planning against your VPC. Pass values across the boundary through outputs plus a terraform_remote_state data source, or publish them to a parameter store. That data source needs read access to the other state file, which as established is read access to its secrets.

09

Drift: How to Detect It and What to Do

Drift is real infrastructure diverging from what Terraform recorded. Detect it with a scheduled terraform plan -detailed-exitcode, which returns 2 when changes are pending.

Drift comes from someone fixing an outage in the console at 2 a.m., another automation editing the same resource, an autoscaler adjusting capacity, or a provider changing a default. It is normal. The danger is that it accumulates unnoticed until an unrelated apply proposes to undo it. When a scheduled plan reports pending changes, there are three possible responses.

1
The configuration is right. Someone made an unsanctioned change. Apply, then add a guardrail so console writes are not possible next time.
2
Reality is right. The emergency fix was correct. Write it into HCL and confirm the plan comes back empty. This is the codification step people skip.
3
Terraform should stop caring. A field is managed elsewhere on purpose. Add it to ignore_changes with a comment naming the owner.
guardrails on the resources that matter
resource "aws_db_instance" "primary" { identifier = "${local.name_prefix}-primary" instance_class = "db.r6g.large" # ... lifecycle { prevent_destroy = true # plan errors instead of destroying # Patch version is owned by the DBA maintenance window ignore_changes = [engine_version] } }

Two habits keep drift small: tag everything Terraform owns with ManagedBy = "terraform" so it is obvious in the console, and remove human write access to the production console, leaving a documented break-glass role that pages someone when used.

10

How to Import Existing Infrastructure Into Terraform

Add an import block naming the address and the provider ID, run terraform plan -generate-config-out=generated.tf to have Terraform draft the resource block, trim it, and re-plan until the plan is empty.

Almost nobody starts on a clean account. Import is how existing infrastructure comes under management, and since Terraform 1.5 the configuration-driven form is far easier than the old CLI command, because Terraform writes the first draft of the HCL for you.

import, generate, verify
# 1. Declare what you are adopting import { to = aws_s3_bucket.artifacts id = "acme-artifacts-prod-7f3a" } # 2. Let Terraform draft the resource block terraform plan -generate-config-out=generated.tf # 3. Review and trim generated.tf, move it into your real files, # then plan again. Empty plan = the import is correct. terraform plan # 4. Record it in state terraform apply # Older CLI form, still supported. Write the resource block first. terraform import aws_s3_bucket.artifacts acme-artifacts-prod-7f3a

Generated configuration includes every attribute the provider knows about, including defaults and computed fields you should not set, so deleting most of it is the normal workflow. Keep only what you intend to control.

Two warnings. One console object often maps to several Terraform resources: a modern S3 bucket splits versioning, encryption, lifecycle rules, and policy into separate resources, each imported separately. And import IDs vary by resource type, with the exact format documented at the bottom of each resource page in the registry.

Sequence the adoption instead of attempting a whole account at once. Pick one low-risk, high-churn area such as S3 buckets or IAM roles, import it, reach an empty plan, put it behind CI, then move on. Networking and databases go last, because that is where a mistaken plan is expensive.

11

The Mistakes Beginners Actually Hit

01

State committed to git

It holds plaintext secrets and invites merge conflicts on a file that cannot be merged. If it happened, rotate every credential in it.

gitignore state on day one
02

No locking

Two applies at once against one state produce duplicated resources and a state file that no longer matches reality.

use_lockfile or a lock table
03

count over a changing list

Remove the first element and every later resource renumbers, so Terraform plans to destroy and recreate all of them.

for_each with stable keys
04

Unpinned providers

Without a constraint and a committed lock file, yesterday's clean plan fails today because a new provider shipped overnight.

commit .terraform.lock.hcl
05

-target as a habit

A break-glass flag. Routine use leaves state partially applied and hides the dependency problem behind it.

fix the graph instead
06

One enormous root module

Plans take twenty minutes, everyone queues on one lock, and a typo can touch production networking.

smaller states, smaller blast radius

Two more worth naming. Provisioners such as local-exec and remote-exec look like an easy finish, but they run once, are not tracked in state, and turn a declarative tool into a fragile script runner. And terraform apply -auto-approve against a live configuration, rather than a saved plan, is how teams ship changes nobody read.

Do not

terraform apply -auto-approve

Re-plans at apply time, so what runs may differ from what was reviewed in the pull request. The gap is small and occasionally expensive.

Do

plan -out=tfplan, then apply tfplan

The artifact you reviewed is the artifact that runs. Store it as a build artifact and the audit trail is real.

12

When Not to Use Terraform

Terraform is a desired-state tool for long-lived resources behind an API. Several common jobs sit outside that description.

13

Terraform vs OpenTofu vs Pulumi vs CloudFormation vs Ansible

Tool You write State Scope Best for
Terraform HCL You host it Any provider Multi-cloud and SaaS infrastructure, largest module community
OpenTofu HCL (same) You host it, encryptable client-side Any provider Teams needing an MPL license or built-in state encryption
Pulumi TypeScript, Python, Go, C#, Java Hosted or self-managed Any provider Teams that want real loops, types, and unit tests in one language
CloudFormation YAML or JSON (or CDK) AWS manages it AWS only All-AWS shops that want no state file to lose, plus StackSets
Ansible YAML playbooks None Machines and some cloud APIs Configuring servers, orchestrating ordered operational tasks

OpenTofu is the fork created after Terraform moved to the Business Source License in August 2023, and it is now under the Linux Foundation. It reads the same HCL and state format, so migration is usually a binary name change in CI. Its distinguishing feature is client-side state encryption, a direct answer to the plaintext-secrets problem above. Terraform keeps the larger registry and the bigger pool of community modules.

Pulumi trades HCL for a general-purpose language. That is a real gain when infrastructure logic needs abstraction or tests, and a real cost when a colleague writes three layers of class hierarchy and the result becomes hard to read in review. HCL's limited expressiveness is partly a feature.

CloudFormation removes the state problem entirely, since AWS holds it, and has change sets and drift detection built in. The price is being AWS-only and writing more verbose templates, which is what CDK exists to soften.

Ansible is complementary rather than competing. It is procedural and excellent at reaching into machines, but it does not build a dependency graph of desired state, so it cannot tell you what would change before it changes. A common split is Terraform for the infrastructure and Ansible for what runs on it.

What to Build Next

One project closes most of the gap between reading Terraform and running it. Take a small piece of infrastructure you already operate by hand, import it, reach an empty plan, move the state to S3 with locking, and put a plan-on-pull-request job in front of it with GitHub Actions. That single loop teaches state, backends, imports, and CI at once. After that, package the result as a module and call it from two environments, run terraform show -json output through a scanner such as tfsec or Checkov, and wire the outputs into your observability stack so new environments arrive already monitored. If this area is new, the DevOps overview and the IAM guide cover ground Terraform assumes, and our free course library has hands-on tracks for the surrounding tools.

The Bottom Line
Terraform is a diff engine for infrastructure. Get state remote and locked, keep environments in separate state files, pin your providers, apply saved plan files, and use imports to bring the systems you already run under the same review process as your application code.

Frequently Asked Questions

What does Terraform actually do?

It reads HCL files describing the infrastructure you want, compares that against a state file recording what it built before and against what the provider API reports now, and prints a plan of every create, update, and destroy needed to close the gap. After you approve, it calls the APIs in dependency order and updates state. Three commands cover most days: terraform init, terraform plan, terraform apply.

Should I use Terraform or OpenTofu in 2026?

Both read the same HCL and state format, so the decision is mostly licensing plus a couple of features. Terraform has been under the Business Source License since August 2023, which allows nearly every normal use but restricts building a competing product. OpenTofu is the MPL-licensed fork under the Linux Foundation, and it added client-side state encryption. If the license is a problem for your organization, or encrypted state matters, OpenTofu is reasonable. Otherwise Terraform's larger community makes it the practical default.

Can Terraform manage infrastructure I created by hand?

Yes, through import. Since Terraform 1.5 you add an import block with the target address and the provider ID, then run terraform plan -generate-config-out=generated.tf to draft the resource block. Trim the generated file, move it into your real configuration, and re-plan until Terraform reports no changes. An empty plan is the acceptance test. The older terraform import ADDRESS ID command still works but needs the resource block written first.

Do I need one state file or many?

Many. One per environment is the minimum, and splitting further by lifecycle is common on larger systems. Separate states bound the blast radius of a bad apply, keep plan times short, and let production use different credentials. The cost is that values crossing a boundary must be published deliberately through outputs and a data source or a parameter store, rather than referenced directly.

Is it safe to edit the state file by hand?

No. State is JSON, so it looks editable, but hand edits break resource addressing, dependency records, and the serial number used for locking. Use terraform state list, show, mv, and rm instead, and prefer moved and removed blocks in configuration, since those are reviewable in a pull request and run for everyone. Keep versioning on the state bucket so a bad write can be rolled back.

Reference documentation: Terraform documentation, Terraform Registry, OpenTofu documentation

Explore More Guides

PA
Our Take

The hard part of Terraform was never the syntax.

People learn HCL in an afternoon. What takes a year is learning where to draw state boundaries. Every painful Terraform story we have heard traces back to the same root: one state file grew to cover an entire account, so plans took forever, everyone queued behind the same lock, and the blast radius of a bad apply was the whole business. Splitting by environment and then by lifecycle is unglamorous work that pays for itself the first time a routine application deploy does not have to plan against your VPC.

The second thing worth internalizing early is that state holds secrets in plaintext. Teams discover this the uncomfortable way, usually when someone notices a database password sitting in a bucket half the company can read. Lock the state bucket down the same day you create it, and treat read access to it as a privilege, not a convenience. OpenTofu's client-side state encryption is a direct answer to this, and it is the single most defensible reason to consider the fork.

One prediction. The plan-and-approve loop is not going away, but the review step is changing shape. Machine-readable plan JSON plus policy engines already catch a large share of the mistakes a human reviewer would miss at 6 p.m. on a Friday, and that checking will keep moving earlier into the pull request. If you are setting up a Terraform practice now, wire terraform show -json into an automated check from the beginning. It is much harder to retrofit once a hundred configurations exist.

PA

About the Publisher

Precision AI Academy

Practitioner-focused AI education ยท tech news, guides, and 145 free courses

Precision AI Academy publishes deep-dives on applied AI engineering for working professionals. Founded by Bo Peng — Kaggle Top 200 data scientist and former university instructor.

Kaggle Top 200 Federal AI Practitioner 5 U.S. Cities Thu–Fri Cohorts