Infrastructure as Code
Clicking through cloud consoles to create resources doesn't scale. Infrastructure as Code (IaC) lets you define your entire infrastructure in configuration files, track changes in version control, and provision environments reproducibly.
What Is Infrastructure as Code?
IaC is the practice of managing infrastructure through machine-readable definition files rather than manual processes. Instead of clicking buttons in a web console to create a server, you write a configuration file that describes that server, commit it to Git, and let a tool provision it for you. Terraform by HashiCorp is the most widely adopted IaC tool, supporting AWS, Azure, GCP, Kubernetes, and hundreds of other providers through a single workflow.
Why It Matters
IaC is how teams manage cloud infrastructure at scale. It enables reproducible environments, peer review of infrastructure changes, automated provisioning, and disaster recovery. If you work with cloud platforms, you'll use IaC daily.
What You'll Learn
- What IaC is and why it replaces manual provisioning
- Terraform fundamentals: providers, resources, and data sources
- The plan/apply workflow
- State management and backends
- Variables, outputs, and locals
- Modules for reusable infrastructure components
- Working with multiple environments
The Problem with Manual Infrastructure
Before IaC, infrastructure was provisioned by hand. An engineer would log into a cloud console, click through menus to create a virtual machine, configure its networking, attach storage, and repeat the process for every resource. This approach has serious problems that compound as systems grow.
Configuration drift is the most insidious. When you manage infrastructure by hand, the actual state of your systems slowly diverges from what anyone thinks it is. Someone resizes a database instance through the console. Another engineer opens a firewall port "temporarily" and forgets to close it. A third updates a load balancer rule. None of these changes are documented. After a few months, no one can confidently describe the current state of the infrastructure.
No history means you cannot answer basic questions. What changed? When did it change? Who changed it? Why? With manual provisioning, changes vanish into the ether the moment someone clicks "Apply" in a console.
No review process means no one checks infrastructure changes before they go live. In Version Control, you learned that pull requests provide code review, discussion, and approval before changes reach main. Manual infrastructure changes skip all of that.
No reproducibility means you cannot reliably recreate an environment. Need a staging environment that matches production? Good luck clicking through 200 console screens and getting every setting right. Need to recover from a disaster? You are rebuilding from memory.
Human error at scale is inevitable. Humans make mistakes. The more resources you manage manually, the more mistakes accumulate. A typo in a security group rule, a wrong AMI ID, a misconfigured subnet — these errors are costly in production and nearly undetectable without automation.
IaC solves all of these problems. Your infrastructure is defined in files that live in a Git repository. Every change is a commit with a message, a diff, and a pull request. Environments are reproducible because the same configuration always produces the same result. And Terraform shows you exactly what will change before it changes anything.
What Is Terraform?
Terraform is an open-source infrastructure as code tool created by HashiCorp. You write configuration files in HCL (HashiCorp Configuration Language), and Terraform translates those files into API calls to provision and manage resources across cloud providers, SaaS platforms, and other services.
Terraform is declarative: you describe the desired state of your infrastructure, and Terraform figures out how to get there. You do not write step-by-step instructions ("create this, then create that, then attach them"). Instead, you say "I want a network with these properties and a server connected to it" and Terraform determines the correct order of operations, handles dependencies, and makes the API calls.
Terraform is provider-agnostic: the same workflow and language work across AWS, Azure, GCP, Docker, Kubernetes, GitHub, Cloudflare, Datadog, and hundreds of other platforms. Each platform has a provider plugin that translates HCL configuration into the specific API calls that platform requires.
Terraform is stateful: it maintains a record of what it has created, so it knows what already exists and what needs to change. This is what makes terraform plan possible — it can compare your desired configuration against the real world and tell you exactly what will happen before you apply anything.
Installing Terraform
On Ubuntu
HashiCorp provides an official APT repository:
sudo apt update && sudo apt install -y gnupg software-properties-common
wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
On macOS
Using Homebrew:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
Verify the Installation
terraform version
Terraform v1.9.5
on linux_amd64
If you see a version number, Terraform is ready.
Try It: Install Terraform on your system using the commands above. Run
terraform versionto confirm it works. Then runterraform -helpto see the list of available subcommands.
The Terraform Workflow
Terraform follows a four-step workflow: Write, Plan, Apply, Destroy. Every Terraform project cycles through these steps.
| Step | Command | What It Does |
|---|---|---|
| Write | (your editor) | Define infrastructure in .tf files using HCL |
| Plan | terraform plan | Preview what Terraform will create, modify, or destroy |
| Apply | terraform apply | Execute the plan and provision real resources |
| Destroy | terraform destroy | Tear down all resources managed by this configuration |
Write is where you define what you want. You create .tf files that describe providers, resources, variables, and outputs.
Plan is where you verify. Terraform compares your configuration against its state file and the real world, then shows you a detailed preview. Nothing changes yet. This is your chance to review before committing to anything.
Apply is where Terraform makes it real. It creates, modifies, or deletes resources to match your configuration. You must confirm with yes before Terraform proceeds.
Destroy is for tearing everything down. When you no longer need the infrastructure, terraform destroy removes every resource that Terraform manages.
This workflow mirrors the Git workflow you learned in Version Control: write changes, review them (plan/diff), commit them (apply/commit). The habit of reviewing before acting is the same.
HCL Syntax
HCL (HashiCorp Configuration Language) is the language you write Terraform configurations in. It is purpose-built for defining infrastructure and is designed to be readable by both humans and machines.
Blocks
HCL is organized into blocks. A block has a type, zero or more labels, and a body enclosed in curly braces:
block_type "label_1" "label_2" {
argument_name = "argument_value"
}
For example, a resource block has two labels — the resource type and the resource name:
resource "docker_container" "web" {
name = "web-server"
image = docker_image.nginx.image_id
}
Arguments
Arguments assign values inside blocks. They use the name = value syntax:
name = "web-server"
ports = [80, 443]
env = ["NODE_ENV=production"]
Expressions and References
You can reference other resources, variables, and built-in functions:
# Reference another resource's attribute
image = docker_image.nginx.image_id
# Reference a variable
name = var.container_name
# Use a built-in function
tags = merge(var.default_tags, { Name = "web-server" })
# String interpolation
name = "${var.project}-${var.environment}-web"
Comments
HCL supports three comment styles:
# Single-line comment (most common)
// Also a single-line comment
/* Multi-line
comment */
File Organization
Terraform loads all .tf files in a directory as a single configuration. By convention, files are organized as:
| File | Contents |
|---|---|
main.tf | Primary resource definitions |
variables.tf | Input variable declarations |
outputs.tf | Output value declarations |
providers.tf | Provider configuration and version constraints |
terraform.tfvars | Variable values (not committed if it contains secrets) |
You are free to name files however you want — Terraform does not care about filenames, only file extensions. But following this convention makes projects immediately navigable.
Providers
Providers are plugins that let Terraform interact with external APIs. Every resource you create in Terraform belongs to a provider. The AWS provider knows how to create EC2 instances and S3 buckets. The Docker provider knows how to create containers and images. The Kubernetes provider knows how to create pods and deployments.
Configuring a Provider
You declare which providers you need in a required_providers block, then configure each one:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {}
The source tells Terraform where to download the provider from (the Terraform Registry). The version constraint pins the provider to a compatible range — ~> 3.0 means "any 3.x version but not 4.0."
terraform init
Before you can use any provider, you must run terraform init. This command downloads the specified providers and sets up the working directory:
terraform init
Initializing the backend...
Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 3.0"...
- Installing kreuzwerker/docker v3.0.2...
- Installed kreuzwerker/docker v3.0.2 (self-signed, key ID BD080C4571C6104C)
Terraform has been successfully initialized!
Terraform downloads providers into a .terraform directory. This directory should be in your .gitignore — it is like node_modules or venv. You learned about .gitignore in Version Control; Terraform state files and the .terraform directory are among the most important entries.
Popular Providers
| Provider | Source | What It Manages |
|---|---|---|
| AWS | hashicorp/aws | EC2, S3, RDS, Lambda, VPC, IAM, and hundreds more |
| Azure | hashicorp/azurerm | Virtual machines, storage, AKS, networking, and more |
| GCP | hashicorp/google | Compute Engine, GKE, Cloud Storage, BigQuery, and more |
| Docker | kreuzwerker/docker | Containers, images, networks, and volumes |
| Kubernetes | hashicorp/kubernetes | Pods, deployments, services, config maps, and more |
| GitHub | integrations/github | Repositories, teams, branch protections, and actions |
The Docker provider is ideal for learning because it requires nothing more than Docker installed on your machine — no cloud account, no billing, no risk of unexpected charges. The examples in this section use Docker so you can follow along immediately if you completed the Containers section.
Try It: Create a new directory called
terraform-lab. Inside it, create a file calledmain.tfwith the Docker provider configuration shown above. Runterraform initand observe the output. List the.terraformdirectory withls -la .terraformto see what was downloaded.
Resources
Resources are the most important element in Terraform. A resource block declares a piece of infrastructure you want to exist — a container, a virtual machine, a DNS record, a database.
Resource Syntax
Every resource has a type and a name:
resource "resource_type" "local_name" {
argument = "value"
}
The type (e.g., docker_container) determines what kind of infrastructure is created. The local name (e.g., web) is how you refer to this resource elsewhere in your configuration. Together, they form a unique identifier: docker_container.web.
A Complete Docker Example
This configuration creates a Docker image, a Docker network, and a Docker container. It demonstrates resource dependencies — Terraform automatically determines that the container depends on the image and the network:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {}
resource "docker_image" "nginx" {
name = "nginx:alpine"
keep_locally = false
}
resource "docker_network" "web_network" {
name = "web-network"
}
resource "docker_container" "web" {
name = "web-server"
image = docker_image.nginx.image_id
ports {
internal = 80
external = 8080
}
networks_advanced {
name = docker_network.web_network.name
}
}
Notice the references: docker_image.nginx.image_id tells Terraform to use the image ID from the docker_image.nginx resource. docker_network.web_network.name references the network name. Terraform uses these references to build a dependency graph — it knows it must create the image and network before the container.
Resource References
The general pattern for referencing a resource attribute is:
resource_type.local_name.attribute
For example:
| Reference | Returns |
|---|---|
docker_image.nginx.image_id | The ID of the pulled Docker image |
docker_network.web_network.name | The name of the Docker network |
docker_container.web.id | The container's unique ID |
Every resource type exports different attributes. The provider documentation lists what is available for each resource.
Try It: Copy the complete Docker example above into your
main.tffile (replacing the previous content). Runterraform init(if you have not already), then proceed to the next section to plan and apply it.
Plan and Apply in Detail
The plan/apply workflow is the heart of Terraform. It gives you a preview of every change before anything happens, then executes only what you approve.
terraform plan
Run terraform plan to see what Terraform will do:
terraform plan
Terraform compares your configuration against its state file (which starts empty) and produces output like this:
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# docker_image.nginx will be created
+ resource "docker_image" "nginx" {
+ id = (known after apply)
+ image_id = (known after apply)
+ keep_locally = false
+ name = "nginx:alpine"
}
# docker_network.web_network will be created
+ resource "docker_network" "web_network" {
+ driver = (known after apply)
+ id = (known after apply)
+ name = "web-network"
}
# docker_container.web will be created
+ resource "docker_container" "web" {
+ id = (known after apply)
+ image = (known after apply)
+ name = "web-server"
+ ports {
+ external = 8080
+ internal = 80
}
+ networks_advanced {
+ name = "web-network"
}
}
Plan: 3 to add, 0 to change, 0 to destroy.
Understanding Plan Symbols
| Symbol | Meaning | Color |
|---|---|---|
+ | Resource will be created | Green |
~ | Resource will be modified in-place | Yellow |
- | Resource will be destroyed | Red |
-/+ | Resource will be destroyed and recreated | Red/Green |
Values shown as (known after apply) are attributes that do not exist until the resource is actually created — like IDs assigned by the Docker daemon or cloud provider.
The summary line at the bottom is critical: Plan: 3 to add, 0 to change, 0 to destroy. Always read this line. If you expect to add 3 resources and the plan says "1 to destroy," stop and investigate.
terraform apply
When the plan looks correct, apply it:
terraform apply
Terraform shows the plan again and asks for confirmation:
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
Type yes and press Enter. Terraform provisions the resources:
docker_image.nginx: Creating...
docker_image.nginx: Creation complete after 8s [id=sha256:abc123...]
docker_network.web_network: Creating...
docker_network.web_network: Creation complete after 1s [id=def456...]
docker_container.web: Creating...
docker_container.web: Creation complete after 2s [id=ghi789...]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
You can skip the confirmation prompt with terraform apply -auto-approve, but only do this in automated pipelines — never in interactive use. The confirmation prompt is a safety net.
Verifying Resources
After applying, you can verify the resources exist. If you used the Docker example:
docker ps
CONTAINER ID IMAGE COMMAND PORTS NAMES
abc123def456 nginx:alpine "/docker-entrypoint..." 0.0.0.0:8080->80/tcp web-server
You can also open http://localhost:8080 in a browser and see the nginx welcome page.
terraform destroy
When you are done, tear everything down:
terraform destroy
Terraform shows a plan of what it will remove and asks for confirmation. Type yes to proceed. All resources managed by this configuration are deleted.
Destroy complete! Resources: 3 destroyed.
Try It: Run
terraform planon the Docker configuration from the previous section. Read the output carefully — identify each resource that will be created and note the(known after apply)values. Then runterraform apply, typeyes, and verify the container is running withdocker ps. Visithttp://localhost:8080in your browser. Finally, runterraform destroyto clean up.
State
Terraform state is how Terraform knows what it has created. After every apply, Terraform writes a state file — terraform.tfstate — that maps your configuration to real-world resources.
What State Tracks
The state file is a JSON document that records:
- Every resource Terraform manages
- The attributes of each resource (IDs, IP addresses, names)
- Dependencies between resources
- Metadata about the configuration
When you run terraform plan, Terraform reads the state file, queries the real-world infrastructure, compares both against your configuration, and determines what needs to change. Without state, Terraform would have no way to know what it previously created.
Why State Matters
State enables several critical capabilities:
- Mapping configuration to real resources (your
docker_container.webis container IDabc123) - Dependency tracking so Terraform destroys resources in the correct order
- Performance — Terraform can read state instead of querying every resource from the API on every run
- Detecting drift — if someone changes a resource outside of Terraform, the next plan reveals the difference
Rules for State
Never edit terraform.tfstate by hand. The file format is complex and a single mistake can corrupt it, leaving Terraform unable to manage your resources. If you need to modify state, use terraform state commands.
Never commit state to Git. State files often contain sensitive information — database passwords, API keys, IP addresses. Add these entries to your .gitignore:
# Terraform
*.tfstate
*.tfstate.backup
.terraform/
.terraform.lock.hcl
You learned in Version Control that .gitignore prevents files from being tracked. Terraform state files are one of the most important things to exclude.
Remote Backends
For team use, state must be stored remotely so that everyone works from the same source of truth. The most common pattern is an S3 bucket with DynamoDB locking (for AWS):
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/infrastructure.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
This configuration tells Terraform to:
- Store the state file in the
my-terraform-stateS3 bucket - Use
prod/infrastructure.tfstateas the path within the bucket - Lock the state using a DynamoDB table so two people cannot apply simultaneously
- Encrypt the state file at rest
Other backend options include Azure Blob Storage, Google Cloud Storage, and Terraform Cloud. The principle is the same: state lives in a shared, locked, encrypted location — never on someone's laptop.
State Locking
When using remote backends, Terraform locks the state file during operations to prevent concurrent modifications. With the S3 backend, locking is provided by a DynamoDB table:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
If someone runs terraform apply while another apply is in progress, they will see a lock error. This prevents state corruption from simultaneous writes.
State Commands
Terraform provides commands to inspect and manipulate state:
# List all resources in state
$ terraform state list
aws_instance.web
aws_security_group.web_sg
aws_vpc.main
# Show details of a specific resource
$ terraform state show aws_instance.web
# Move a resource (when refactoring — e.g., renaming)
$ terraform state mv aws_instance.web aws_instance.app_server
# Remove a resource from state (without destroying it)
$ terraform state rm aws_instance.legacy
terraform state mv is essential when refactoring. Without it, renaming a resource would cause Terraform to destroy the old name and create the new name — even though nothing about the actual resource changed.
Try It: After applying the Docker configuration, examine the state file: run
terraform state listto see all managed resources. Then runterraform state show docker_container.webto see the container's full details. Compare what you see with the output ofdocker inspect web-server.
Variables
Hardcoding values in resource blocks creates rigid, unreusable configurations. Variables let you parameterize your Terraform code so the same configuration can deploy to different environments, regions, or accounts simply by changing input values.
Declaring Variables
Declare variables in a variable block:
variable "container_name" {
description = "Name for the Docker container"
type = string
default = "web-server"
}
variable "external_port" {
description = "Host port to map to the container"
type = number
default = 8080
}
variable "enable_logging" {
description = "Whether to enable verbose logging"
type = bool
default = false
}
Variable Types
| Type | Example Value | Description |
|---|---|---|
string | "web-server" | A sequence of characters |
number | 8080 | A numeric value |
bool | true | True or false |
list(string) | ["80", "443"] | An ordered sequence of values |
map(string) | { env = "prod", team = "infra" } | A collection of key-value pairs |
object({...}) | { name = string, port = number } | A structured type with named attributes |
Using Variables
Reference a variable with the var. prefix:
resource "docker_container" "web" {
name = var.container_name
image = docker_image.nginx.image_id
ports {
internal = 80
external = var.external_port
}
}
Setting Variable Values
Terraform provides multiple ways to set variable values, evaluated in this order of precedence (highest to lowest):
- Command line:
terraform apply -var="container_name=api-server" - Variable file:
terraform apply -var-file="prod.tfvars" terraform.tfvarsfile (automatically loaded):
container_name = "production-web"
external_port = 80
enable_logging = true
- Environment variables:
export TF_VAR_container_name="staging-web" - Default value in the variable declaration
- Interactive prompt if no value is found anywhere
Outputs
Output blocks expose values from your configuration. They are useful for displaying information after apply and for passing values between modules:
output "container_id" {
description = "ID of the Docker container"
value = docker_container.web.id
}
output "container_url" {
description = "URL to access the web server"
value = "http://localhost:${var.external_port}"
}
After terraform apply, outputs are displayed:
Outputs:
container_id = "abc123def456"
container_url = "http://localhost:8080"
You can also retrieve outputs later with terraform output.
Locals
Local values are computed values within your configuration — like variables, but they are internal and not set by the user. Use them to reduce repetition:
locals {
common_tags = {
project = var.project_name
environment = var.environment
managed_by = "terraform"
}
container_name = "${var.project_name}-${var.environment}-web"
}
resource "docker_container" "web" {
name = local.container_name
# ...
}
Locals are referenced with the local. prefix (note: singular local, not locals).
Try It: Refactor the Docker configuration to use variables. Create a
variables.tffile with variables forcontainer_name,external_port, andimage_name. Create aterraform.tfvarsfile that sets values. Add anoutputs.tfwith outputs for the container ID and URL. Runterraform applyand observe the outputs. Then change a variable value and runterraform planto see how Terraform handles the update.
Modules
As your infrastructure grows, you will find yourself repeating the same patterns. Modules let you package a set of resources into a reusable component — like a function in programming.
What Modules Are
A module is a directory containing .tf files. Every Terraform configuration is technically a module (the root module). When you call another module from your configuration, it becomes a child module. In Programming, you learned to write functions that encapsulate logic and accept parameters. Modules serve the same purpose for infrastructure.
Module Structure
A typical module directory looks like this:
modules/
web-app/
main.tf # Resource definitions
variables.tf # Input variables (parameters)
outputs.tf # Output values (return values)
The module's main.tf defines resources using variables as inputs:
# modules/web-app/main.tf
resource "docker_image" "app" {
name = var.image_name
keep_locally = false
}
resource "docker_container" "app" {
name = var.container_name
image = docker_image.app.image_id
ports {
internal = var.internal_port
external = var.external_port
}
}
The module's variables.tf declares what inputs it accepts:
# modules/web-app/variables.tf
variable "image_name" {
description = "Docker image to run"
type = string
}
variable "container_name" {
description = "Name for the container"
type = string
}
variable "internal_port" {
description = "Container port"
type = number
}
variable "external_port" {
description = "Host port"
type = number
}
The module's outputs.tf exposes values to the caller:
# modules/web-app/outputs.tf
output "container_id" {
value = docker_container.app.id
}
Calling a Module
From your root configuration, you call the module with a module block:
module "frontend" {
source = "./modules/web-app"
image_name = "nginx:alpine"
container_name = "frontend"
internal_port = 80
external_port = 8080
}
module "backend" {
source = "./modules/web-app"
image_name = "node:alpine"
container_name = "backend"
internal_port = 3000
external_port = 3000
}
With one module, you created two fully configured applications. This is the power of modules — define the pattern once, use it many times.
The Terraform Registry
The Terraform Registry hosts thousands of community and official modules for common infrastructure patterns — VPCs, Kubernetes clusters, database setups, and more. Using a registry module:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0"
name = "production-vpc"
cidr = "10.0.0.0/16"
}
Always pin module versions. An unpinned module can change underneath you and break your infrastructure on the next apply.
When to Use Modules
Use modules when:
- You repeat the same resource pattern more than once
- You want to enforce standards (e.g., every service gets a load balancer and health check)
- You want to abstract complexity (e.g., a "database" module hides 15 underlying resources)
- You want different teams to share infrastructure patterns
Do not use modules prematurely. If a pattern appears only once, keep it inline. Extract a module when repetition or complexity justifies it.
Try It: Create a
modules/web-appdirectory withmain.tf,variables.tf, andoutputs.tfusing the examples above. Modify your rootmain.tfto call the module twice — once for a frontend and once for a backend. Runterraform init(required when adding modules), thenterraform planandterraform apply. Verify both containers are running withdocker ps.
Multiple Environments
Real projects run in multiple environments — development, staging, and production at minimum. Terraform provides several approaches for managing environment-specific configurations.
Approach 1: Variable Files Per Environment
The simplest approach uses a separate .tfvars file for each environment:
project/
main.tf
variables.tf
outputs.tf
environments/
dev.tfvars
staging.tfvars
prod.tfvars
Each file sets environment-specific values:
# environments/dev.tfvars
environment = "dev"
container_name = "dev-web"
external_port = 8080
# environments/prod.tfvars
environment = "prod"
container_name = "prod-web"
external_port = 80
Apply with a specific file:
terraform apply -var-file="environments/dev.tfvars"
terraform apply -var-file="environments/prod.tfvars"
Approach 2: Directory Per Environment
Each environment gets its own directory with its own state file:
environments/
dev/
main.tf
terraform.tfvars
staging/
main.tf
terraform.tfvars
prod/
main.tf
terraform.tfvars
modules/
web-app/
main.tf
variables.tf
outputs.tf
Each environment's main.tf calls the shared modules with environment-specific values. This gives complete isolation — each environment has its own state and can be applied independently.
Approach 3: Workspaces
Terraform workspaces let you maintain separate state files within the same configuration directory:
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select dev
terraform apply
terraform workspace select prod
terraform apply
List workspaces:
terraform workspace list
default
dev
* prod
staging
The current workspace is available as terraform.workspace in your configuration:
resource "docker_container" "web" {
name = "${terraform.workspace}-web-server"
# ...
}
Comparing Approaches
| Approach | Isolation | Complexity | Best For |
|---|---|---|---|
| Var files | Shared state (risky) | Low | Small projects, single developer |
| Directories | Full isolation | Medium | Production systems, teams |
| Workspaces | Separate state, shared config | Low | Similar environments with minor differences |
Most production teams use the directory per environment approach because it provides the strongest isolation. A mistake in the dev configuration cannot accidentally affect production state.
Best Practices
These practices come from hard-won experience managing Terraform at scale. Following them from the start saves pain later.
Pin provider and module versions. Without version constraints, terraform init may download a new provider version that introduces breaking changes. Use ~> constraints:
version = "~> 5.0" # allows 5.x but not 6.0
Use remote state with locking. Local state works for learning. In any team environment, use a remote backend with locking to prevent concurrent modifications.
Never hardcode values. Use variables for anything that might change between environments, regions, or deployments. This is the same principle you learned in Shell Scripting and Programming — avoid magic numbers and strings.
Always review the plan. terraform plan exists to protect you. Read it carefully before every apply, especially in production. The plan is your last line of defense against unintended changes.
Use modules for repeated patterns. If you create the same three resources together more than once, extract them into a module. But do not over-modularize — modules add indirection.
Tag every resource. Tags make resources identifiable, searchable, and assignable to cost centers. At minimum, tag with project, environment, and managed-by:
tags = {
project = "web-platform"
environment = "production"
managed_by = "terraform"
}
Format your code. Run terraform fmt before committing. It enforces consistent style across your team, just like linters enforce style in your Python code.
Use .gitignore from day one. Exclude .terraform/, *.tfstate, *.tfstate.backup, and any *.tfvars files that contain secrets.
Drift Detection
Drift occurs when the actual state of infrastructure differs from what Terraform expects — usually because someone made manual changes through the cloud console.
# Detect drift by running plan (no changes should appear if everything is in sync)
$ terraform plan
If terraform plan shows changes you didn't make, that's drift. You have three options:
- Accept the drift — update your
.tffiles to match the actual state - Correct the drift — run
terraform applyto force the infrastructure back to the desired state - Ignore the drift — add
ignore_changeslifecycle rules for attributes that are legitimately changed externally
Running terraform plan regularly (even in CI) helps catch drift early before it causes problems.
Terraform Command Reference
| Command | Description |
|---|---|
terraform init | Initialize working directory, download providers and modules |
terraform plan | Preview changes without applying them |
terraform apply | Apply changes to create, update, or delete resources |
terraform destroy | Destroy all managed resources |
terraform fmt | Format configuration files to canonical style |
terraform validate | Check configuration for syntax errors |
terraform output | Display output values from state |
terraform state list | List all resources in state |
terraform state show <resource> | Show details of a specific resource |
terraform workspace list | List all workspaces |
terraform workspace new <name> | Create a new workspace |
terraform workspace select <name> | Switch to a workspace |
terraform import <resource> <id> | Import an existing resource into state |
terraform taint <resource> | Mark a resource for recreation on next apply |
terraform graph | Generate a visual dependency graph (DOT format) |
Try It: Run
terraform fmton your configuration files to auto-format them. Then runterraform validateto check for errors. Tryterraform graphand pipe the output to a file — if you have Graphviz installed, you can render it as an image to visualize resource dependencies.
Data Sources
Not everything in Terraform is created by Terraform. Data sources let you query existing resources — things created manually, by other Terraform configurations, or by other tools.
# Look up the latest Ubuntu AMI
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
# Use the data source result in a resource
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}
Data sources use the data block (not resource). They read information but never create, modify, or destroy anything. Common uses:
| Data Source | Purpose |
|---|---|
aws_ami | Find the latest AMI matching criteria |
aws_vpc | Reference an existing VPC |
aws_availability_zones | List available AZs in a region |
aws_caller_identity | Get the current AWS account ID |
Data sources bridge the gap between Terraform-managed and non-Terraform-managed infrastructure. They allow your configuration to adapt to its environment rather than hardcoding values.
Lifecycle Rules
Terraform's default behavior — destroy the old resource, create the new one — is not always safe. Lifecycle rules let you customize how Terraform handles resource changes.
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
lifecycle {
# Create the replacement before destroying the original (zero-downtime)
create_before_destroy = true
# Prevent accidental deletion (Terraform will error if you try)
prevent_destroy = true
# Ignore changes made outside of Terraform (manual console edits)
ignore_changes = [
tags["LastModifiedBy"],
ami,
]
}
}
| Rule | Effect |
|---|---|
create_before_destroy | New resource is created before old is destroyed — prevents downtime |
prevent_destroy | Terraform refuses to destroy the resource — protects databases, critical infra |
ignore_changes | Terraform ignores external changes to specified attributes — avoids drift fights |
Use prevent_destroy on databases, storage, and any resource where data loss would be catastrophic. Use ignore_changes when external systems legitimately modify resource attributes (like auto-scaling tags).
Importing Existing Resources
When you adopt Terraform for existing infrastructure, you need to bring those resources under Terraform management without recreating them. terraform import does this.
# Import an existing AWS instance
$ terraform import aws_instance.web i-0abc123def456
# After import, the resource exists in state but you still need
# to write the matching configuration in your .tf files
The import workflow:
- Write the
resourceblock in your.tffile (empty or partial) - Run
terraform import <resource_address> <cloud_resource_id> - Run
terraform planto see any drift between your config and the actual resource - Update your
.tffile untilterraform planshows no changes
Starting with Terraform 1.5+, you can also use import blocks declaratively:
import {
to = aws_instance.web
id = "i-0abc123def456"
}
Import is a one-time operation per resource. Once imported, Terraform manages the resource normally going forward.
for_each Deep Dive
You learned count for creating multiple instances. for_each is more powerful — it uses maps or sets instead of integers, making your configuration more predictable and maintainable.
count vs for_each
# Problem with count: removing an item shifts all indices
variable "subnets" {
default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}
# With count — removing the first item forces recreation of items 2 and 3
resource "aws_subnet" "counted" {
count = length(var.subnets)
cidr_block = var.subnets[count.index]
vpc_id = aws_vpc.main.id
}
# With for_each — each resource is keyed by a stable identifier
variable "subnets" {
default = {
public-a = "10.0.1.0/24"
public-b = "10.0.2.0/24"
private-a = "10.0.3.0/24"
}
}
resource "aws_subnet" "each" {
for_each = var.subnets
cidr_block = each.value
vpc_id = aws_vpc.main.id
tags = {
Name = each.key
}
}
With for_each, removing public-b only affects that one subnet. With count, removing index 1 shifts index 2 to 1, causing an unnecessary destroy-and-recreate.
| Feature | count | for_each |
|---|---|---|
| Index type | Integer (0, 1, 2...) | String key |
| Remove middle item | Shifts all subsequent items | Only removes that item |
| Best for | Identical copies | Named or configured instances |
Rule of thumb: Use for_each when resources are distinguishable. Use count only when creating N identical copies.
Key Takeaways
- Infrastructure as Code replaces manual, error-prone provisioning with version-controlled, reviewable, reproducible configuration files. The same principles that make Git essential for code make IaC essential for infrastructure.
- Terraform is declarative — you describe what you want, not how to build it. Terraform handles dependency ordering, API calls, and state tracking automatically.
- The plan/apply workflow is your safety net. Always run
terraform planand read the output before applying. The plan shows you exactly what will be created, modified, or destroyed. - State is how Terraform maps configuration to reality. Never edit state by hand, never commit it to Git, and always use remote backends with locking for team work.
- Variables, outputs, and locals make configurations flexible and reusable. Hardcoded values are the enemy of maintainable infrastructure code.
- Modules package resources into reusable components. Define a pattern once, use it across projects and environments.
- Pin your provider and module versions. An unpinned dependency can break your infrastructure without warning.
- Multiple environments (dev, staging, prod) should be isolated. Directory-per-environment is the safest approach for production teams.
- Terraform follows the same review-before-commit discipline as Git. Writing a plan is like writing a diff. Reviewing a plan is like reviewing a pull request. Applying is like merging.
Foundations Path Complete
You have completed all fifteen sections of the Foundations path. Take a moment to see how far you have come and how everything connects.
You started with Introduction to Computers, learning how hardware works — CPUs, memory, storage, and how binary underpins everything a computer does. That gave you the foundation to understand OS Fundamentals, where you learned how operating systems manage processes, memory, file systems, users, cgroups, and namespaces. You then put that knowledge into practice with Linux, working directly in the Ubuntu terminal to manage packages, navigate file systems, and configure permissions.
With a working Linux environment, you learned Text Editing with Vim — the editor available on every server you will ever SSH into. That prepared you for Shell Scripting, where you automated tasks with Bash — variables, conditionals, loops, and pipes. You then expanded into general-purpose programming with Python, learning data structures, functions, classes, testing, and virtual environments.
Version Control with Git and GitHub introduced the discipline of tracking every change, reviewing code through pull requests, and collaborating with branches. Networking Fundamentals taught you how computers communicate — IP addresses, TCP/UDP, DNS, HTTP, NAT, proxies, firewalls, and SSH — the invisible plumbing beneath every cloud service and deployment.
API Fundamentals showed you how systems communicate programmatically — REST, HTTP methods, JSON, authentication, and making API calls with curl and Python. Security Fundamentals gave you the mindset and tools to keep everything secure — encryption, certificates, secrets management, and the principle of least privilege.
With those skills in place, you tackled CI/CD, automating the testing and deployment of code with GitHub Actions. You learned Containers with Docker, packaging applications into portable, reproducible units. Container Orchestration with Kubernetes showed you how to run those containers at scale — scheduling, healing, scaling, and networking across clusters of machines.
And now, with Infrastructure as Code and Terraform, you have learned how to define, version, review, and provision all of that infrastructure programmatically. Every section built on the ones before it. The shell scripts you write automate server setup. The Python scripts process data and interact with APIs. Git tracks your Terraform configurations. CI/CD pipelines run terraform plan on pull requests and terraform apply on merge. Docker containers run the applications that Terraform provisions infrastructure for. Kubernetes orchestrates those containers on the infrastructure Terraform creates.
This is not a collection of isolated skills. It is a connected system. The Foundations path has given you the shared base that every specialization builds on — whether you pursue DevOps, Cloud Engineering, SRE, Platform Engineering, or AI/ML Infrastructure.