Infrastructure as Code: Terraform in Practice
Clicking through cloud consoles is slow, error-prone, and difficult to reproduce. Infrastructure as Code (IaC) describes resources in code, with Terraform as the leading tool. This guide covers its declarative model, core workflow, state, modules, production backends, and team collaboration.
Key Points
- Topic Focus: DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Infrastructure as Code: Terraform in Practice
If you still log in to the AWS console, click through a dozen forms to create a VPC, several subnets, a set of security groups, and an RDS instance, then repeat the entire process for a test environment, you have probably experienced the despair of thinking, "I built this before, but I cannot remember the parameters." The problem with configuring infrastructure manually through a console is not just that it is slow. It is not reproducible, auditable, or reversible.
Infrastructure as Code (IaC) describes cloud resources in code and uses a tool to create, modify, and delete them. Terraform is the most widely adopted and mature tool in this field. This article avoids tricks and focuses on what engineers must fully understand before running Terraform in production: the declarative model, core commands, state, modules, remote backends, and the pitfalls that tend to stick only after you encounter them.
1. Why IaC Matters: Leave Manual Console Work Behind
Let us start with the value proposition; otherwise, there is little motivation to learn it. Manually creating resources through a console has a somewhat disparaging industry nickname: ClickOps. Its costs are hidden but extremely high:
- Slow: Even an experienced engineer may need half an hour to create a standard environment with a VPC, subnets, NAT, RDS, security groups, and IAM. A Terraform
applytakes about three minutes. - Not reproducible: You create a dev environment that works well, and your manager asks for an identical staging environment. You recreate it manually, but staging is missing one security-group port and uses a different RDS minor version. A production bug follows, and you spend two days discovering that the environments differ.
- Not auditable: Three months later, someone asks, "Who enabled public access on this S3 bucket, and when?" The console cannot tell you. With code in Git, every line has a commit, author, and timestamp.
- Not reversible: If a manual configuration change breaks something, you may want to restore the previous state, but there is no "previous state." With code, you can run
git checkoutand thenapplyagain to restore it. - Cannot integrate with CI/CD: Infrastructure changes need reviews and pipelines, which requires infrastructure to be represented as code.
In short: code is documentation, code is an audit trail, and code enables rollback. Once you experience changing one line of code and having the environment synchronize automatically, you will not want to return to ClickOps.
2. Terraform's Declarative Model
Terraform describes resources using HCL (HashiCorp Configuration Language). Its central idea is declarative configuration: you tell it, "This is the state I want," and it determines how to move from the current state to the target state.
Compare the two approaches:
- Imperative: "First create a VPC, then create three subnets, then associate the subnets with a route table..." You focus on the steps.
- Declarative: "I want a VPC containing three subnets." You focus on the result, and Terraform determines the steps.
Here is a minimal usable .tf file:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
resource "aws_s3_bucket" "assets" {
bucket = "my-app-assets-prod-9f3k2" # Globally unique; remember to add a random suffix
tags = {
Name = "assets"
Environment = "prod"
ManagedBy = "terraform"
}
}
This code says, "I want an S3 bucket named xxx with these tags." After you run apply, it exists. If you then change bucket = "..." or remove one tag line and run apply again, Terraform removes precisely that tag and leaves everything else untouched. That is the power of declarative configuration: it performs only the changes required to make reality match the code, with no unnecessary operations.
3. Core Commands: init / plan / apply / destroy
Memorize these four commands. This is the everyday workflow.
terraform init # Initialize: download provider plugins and configure the backend
terraform plan # Preview: calculate changes without applying them (critical!)
terraform apply # Apply: make the changes after asking for confirmation again
terraform destroy # Destroy: delete every resource in this stack (use with caution)
init prepares the environment. Based on required_providers, it downloads provider plugins for AWS, Azure, or GCP. Based on the backend configuration, it connects to remote state storage. After cloning the code onto another machine, init is the first command to run.
plan is Terraform's single most valuable command. It compares the target state described by the code with the current state recorded in the state file and calculates a diff: which resources will be created (+), modified (~), or destroyed (-). It then prints the result for you to inspect. Before every apply, run plan and understand the diff. Countless incidents have happened because someone ran apply without reviewing the plan and accidentally deleted a production database.
terraform plan
# The output looks roughly like this:
# # aws_s3_bucket.assets will be created
# + resource "aws_s3_bucket" "assets" {
# + bucket = "my-app-assets-prod-9f3k2"
# ...
# }
# Plan: 1 to add, 0 to change, 0 to destroy.
apply executes the changes. It displays another confirmation prompt and proceeds only after you enter yes. CI pipelines usually use -auto-approve, but production environments should retain manual confirmation or place apply behind a separate approval gate.
destroy deletes every resource managed by this configuration. Use it to clean up local experiments or decommission an environment. Do not run it casually in production, and ideally isolate destroy permissions from ordinary access to prevent accidental execution.
4. State: The Infrastructure "Source of Truth"
State is the part of Terraform that beginners most often overlook and the part most likely to cause serious problems.
How does Terraform know which resources exist in the real world and what state they are in? It does not query every cloud API from scratch each time, which would be too slow and costly for the cloud provider. Instead, it maintains a state file (terraform.tfstate), a JSON inventory containing every resource's real cloud ID, attributes, and dependencies.
The purpose of state is to provide the "current state" baseline used by plan to calculate a diff. Terraform compares the target state in code with the current state in the state file, calculates the diff, performs the operations in the cloud, and then updates state.
State is therefore critical, with two nonnegotiable rules:
- Do not lose it. If state is lost, you lose Terraform's "remote control" over those resources. Terraform can no longer find them even though they still exist in the cloud, leaving you with a mess that must be resolved by importing the resources again or deleting them manually through the console. State needs versioning just like code, or, more securely, storage in a versioned object store.
- Do not let writes conflict. If two people modify the same state concurrently, the later write can overwrite the earlier one. Local state may be acceptable for individual development, but team collaboration requires a remote backend with locking, as covered in the next section.
Here is how to inspect state:
terraform state list # List every resource in state
terraform state show aws_s3_bucket.assets # Show details for one resource
terraform refresh # Explicitly synchronize the latest cloud state (use with caution)
An important principle: state is not a cache; it is a source of truth. It records what Terraform believed to be "reality" after the last apply, which may not match the cloud's current state. This leads to the drift problem discussed below.
5. Modules: Combine Resources into Reusable Units
After using Terraform for a while, you may find that a project's main.tf keeps growing. The VPC, subnets, NAT, route tables, security groups, RDS, EC2, and IAM configuration all end up in one file with three or four hundred lines, making every change feel risky.
That is when you should introduce a module. A module packages a group of related resources into a reusable unit with input variables and output values. For example, every environment may need "a standard VPC with two public subnets, two private subnets, and NAT." Package that combination as a module, and you can reuse it everywhere.
A typical directory structure looks like this:
modules/
vpc/
main.tf # Resource definitions
variables.tf # Input parameters
outputs.tf # Output values
environments/
prod/
main.tf # Calls the module here
modules/vpc/main.tf (simplified):
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.name}-vpc" }
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
availability_zone = var.azs[count.index]
tags = { Name = "${var.name}-public-${count.index}" }
}
environments/prod/main.tf calls it:
module "vpc" {
source = "../../modules/vpc"
name = "prod"
cidr = "10.20.0.0/16"
public_subnets = ["10.20.1.0/24", "10.20.2.0/24"]
azs = ["ap-northeast-1a", "ap-northeast-1c"]
}
resource "aws_db_instance" "main" {
allocated_storage = 100
engine = "postgres"
instance_class = "db.t4g.medium"
db_subnet_group_name = module.vpc.db_subnet_group_name # Use the module's output
}
The real value of modules is not writing less code. It is abstracting a stable environment skeleton so that dev, staging, and prod share the same template and differ only in parameters. Consistency between environments then becomes structural rather than dependent on manual oversight.
The community already provides many modules, such as the official AWS module terraform-aws-modules/vpc/aws. They are high quality and have already addressed many common pitfalls. In production, prefer proven open-source modules instead of rebuilding everything from scratch.
6. Production-Grade State Backends: S3 + DynamoDB Locking
For local development, state defaults to terraform.tfstate in the current directory, which is sufficient. But teams must never work this way. Whoever has the file locally effectively controls it, state cannot be shared, and concurrent operations are not protected.
The production standard is a remote backend with state locking. On AWS, the classic combination is S3 for state storage and DynamoDB for distributed locking:
terraform {
backend "s3" {
bucket = "my-tf-state-prod-9f3k2"
key = "prod/network/terraform.tfstate" # State path within the bucket
region = "ap-northeast-1"
dynamodb_table = "tf-locks" # Lock table
encrypt = true # Server-side encryption
}
}
The mechanism works as follows:
- S3 stores state centrally. Every team member and CI worker reads and writes from the same place, so everyone sees the same source of truth.
- DynamoDB locking: When someone runs
apply, Terraform first writes a lock record to DynamoDB. During that time, another person'splanorapplyis blocked immediately with a message that the state is in use. Terraform releases the lock automatically after apply completes. This eliminates the risk of concurrent state updates overwriting one another. - encrypt = true: State is encrypted server-side in S3. This must be enabled because, as the next section explains, state may contain secrets.
Small teams that do not want to maintain S3 and DynamoDB themselves can use Terraform Cloud, which is managed by HashiCorp. Even its free tier provides remote state, locking, and change history with no operational overhead. It is a practical option.
7. Common Pitfalls: Drift, Import, and Sensitive Values
This is the kind of section that can only be written after encountering these problems firsthand.
Pitfall 1: State drift. State records what Terraform "believes" the state to be. If someone bypasses Terraform and changes a resource directly through the console, such as manually editing a security-group rule, or another pipeline modifies the same RDS instance, state and reality diverge. During the next plan, Terraform will try to "change the resource back" because the code and state still describe the previous configuration.
Response: Never bypass Terraform to modify production resources. Enforce this through process and permission isolation. Run terraform plan regularly for drift detection. When you find a discrepancy, either update the code to match reality or manually restore reality to match the code.
Pitfall 2: Bringing existing resources under management with import. Most teams already have many cloud resources created manually and later want Terraform to manage them. What happens if you write the code and run apply directly? Terraform assumes the resources do not exist and tries to create them, which fails because their names already exist.
The correct approach is import:
terraform import aws_s3_bucket.assets my-app-assets-prod-9f3k2
This "registers" the existing cloud bucket in state, but it does not generate code. You still need to write the .tf configuration describing the bucket's attributes until terraform plan reports No changes. At that point, the code and reality are aligned, and the resource has been imported successfully. The process is tedious but necessary. Terraform 1.5+ introduced the import block, which supports declarative imports in code and can generate configuration automatically, saving considerable effort.
Pitfall 3: State contains sensitive values. This is one of the easiest traps for beginners to fall into. If your RDS resource contains password = var.db_password, terraform.tfstate stores the password verbatim in plaintext because Terraform cannot determine which attributes should be considered sensitive. If state is committed to an unencrypted Git repository or stored in S3 without encryption, the secret is exposed.
Response:
- Always enable server-side encryption for state (
encrypt = true). - Apply strict IAM permissions to the state bucket and never commit state to Git (add
*.tfstateto.gitignore). - Mark sensitive variables with
sensitive = truesoplanandapplymask them in output, but understand that they remain plaintext in state. - Read actual secrets from a dedicated secret manager such as AWS Secrets Manager or Vault. Terraform should reference them, not store them.
8. Choosing a Tool: Terraform vs. Pulumi vs. CDK
Finally, let us discuss tool selection because Terraform is not the only IaC option.
Terraform: Declarative HCL with the broadest multi-cloud coverage. AWS, Azure, GCP, Alibaba Cloud, and hundreds of SaaS products all have official providers. It has the most mature ecosystem and the most comprehensive documentation, making it the default choice for multi-cloud environments or teams that want a unified toolchain. Its drawback is that HCL is not a general-purpose programming language. Complex logic such as loops and conditions can feel awkward, and you cannot write unit tests in the usual way.
Pulumi: Defines infrastructure using general-purpose programming languages such as TypeScript, Python, Go, and C#. You can write for loops and functions and use familiar IDEs and testing frameworks. It is very approachable for teams with strong programming skills that do not want to learn HCL. Its ecosystem is not as extensive as Terraform's, but it supports all major cloud platforms.
Cloud-native options such as AWS CDK and Azure Bicep: These are IaC tools provided by cloud vendors. They offer the deepest integration with their respective clouds and the fastest support for new features, but they lock you into a single cloud. Teams that use only one cloud and rely heavily on its advanced features may want to consider them.
My practical recommendation is: choose Terraform for multi-cloud environments or the largest possible ecosystem; choose Pulumi for a single team with a strong engineering culture that dislikes DSLs; choose the vendor's native tool when deeply committed to one cloud. For most teams, Terraform remains the safest default. Its maturity, hiring market, and community modules form an advantage that the other two options will struggle to match in the near term.
Summary
- The value of IaC is reproducibility, auditability, reversibility, and speed. The real cost of ClickOps does not appear immediately; it appears three months later during a late-night investigation into environmental differences.
- Terraform is declarative: you describe the target state, and it calculates and applies the diff. Understanding the changes in
planbefore runningapplyis a fundamental discipline. - State is the source of truth, not a cache. Do not lose it or allow conflicts. Team collaboration requires a remote backend such as S3 with DynamoDB locking or Terraform Cloud.
- Modules abstract the "environment skeleton" so multiple environments share a template and differ only in parameters. In production, prefer community-validated open-source modules.
- Remember the three major pitfalls: drift (detect it with regular plans), import (existing resources are not successfully managed until code and reality are aligned), and sensitive values (state stores plaintext, so use encryption, isolation, and references to a secret manager).
In one sentence: Terraform is fundamentally not about HCL syntax, but about the discipline of "managing infrastructure as code." Being able to write
.tffiles does not mean you have mastered the basics. Using IaC correctly means knowing how to protect state, prevent drift, and establish the practice across a team.
Related
Related Content
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.
Article
Long-Running Tool Calls: The Terminal-State Protocol for Operation, Poll, and Cancel
After the initial request times out, the tool may still be running server-side; issuing a cancel does not mean the side effects have stopped. This article establishes an operation identity, polling watermark, cancellation race handling, and a single terminal-state acceptance point for a single long-running tool call.
Article
Agent Memory Erasure Propagation: From User Forgetting Request to Verifiable Purge
Deleting one row in the primary table won't clear vectors, summaries, caches, or restored backups. This article uses a deletion state machine, generational tombstones, derived lineage, and end-to-end canary verification to truly forget.