- 1. Introduction: The Era of Infrastructure as Code
- Part 1: The Complete Terraform Guide
- 2. Introducing Terraform and Its Architecture
- 3. Installing Terraform and Setting Up the Environment
- 4. The Core Terraform Workflow
- 4.1 terraform init — Initializing the Project
- 4.2 terraform validate — Validating the Syntax
- 4.3 terraform fmt — Formatting the Code
- 4.4 terraform plan — The Execution Plan
- 4.5 terraform apply — Applying the Changes
- 4.6 terraform destroy — Destroying the Infrastructure
- 4.7 terraform output — Reading Output Values
- 5. Terraform State Management
- 6. Terraform Workspace
- 7. Managing Terraform Modules
- 8. Advanced Terraform Features
- 9. An HCL Syntax Cheat Sheet
- 10. A Practical Terraform Example — AWS VPC + EC2 + RDS
- Part 2: The Complete Ansible Guide
- Part 3: Integration, Cheat Sheets, and Troubleshooting
1. Introduction: The Era of Infrastructure as Code
1.1 Why IaC and Configuration Management?
In the cloud-native era, managing infrastructure by hand is no longer an option. Click your way through hundreds of servers, dozens of VPCs, and a tangle of security groups and IAM policies in a console, and you are taking the fast route to "snowflake servers" — every server different, like a snowflake. They cannot be reproduced, they are hard to audit, and a single mistake can bring the whole infrastructure down.
Infrastructure as Code (IaC) and Configuration Management (CM) are the industry's answer to that problem.
- IaC (Infrastructure as Code): declare the infrastructure itself as code, version it, review it, and provision it automatically. Representative tools: Terraform, Pulumi, AWS CloudFormation, OpenTofu
- CM (Configuration Management): install software on the provisioned infrastructure, standardize its configuration, and hold that state. Representative tools: Ansible, Chef, Puppet, SaltStack
The de facto standards in these two areas are Terraform and Ansible.
1.2 How Terraform and Ansible Divide the Work
Terraform and Ansible are not competitors but complements. Each owns a clearly different area of responsibility.
┌──────────────────────────────────────────────────────────────────┐
│ IaC + CM workflow │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
│ │ Terraform │ │ Ansible │ │
│ │ (Provisioning) │──────▶│ (Configuration) │ │
│ │ │ │ │ │
│ │ - Create VPC/Subnet │ │ - Install packages │ │
│ │ - Create EC2/RDS │ │ - Configure Nginx/Apache│ │
│ │ - Create S3 buckets │ │ - Deploy applications │ │
│ │ - Create IAM roles │ │ - Security hardening │ │
│ │ - Security Group │ │ - Install monitoring │ │
│ └─────────────────────┘ └─────────────────────────┘ │
│ │
│ "Builds the infrastructure" "Configures the infrastructure" │
│ Declarative Procedural + Declarative │
│ State-based Agentless (SSH/WinRM) │
└──────────────────────────────────────────────────────────────────┘
1.3 How This Article Is Organized
This article is organized into three parts.
- Part 1 — Terraform: from HCL syntax through the core workflow, state management, workspaces, modules, and advanced features
- Part 2 — Ansible: inventory, ad-hoc commands, playbooks, roles, Vault, and Galaxy
- Part 3 — Integration and cheat sheets: combining Terraform and Ansible, command cheat sheets, and troubleshooting
Part 1: The Complete Terraform Guide
2. Introducing Terraform and Its Architecture
2.1 What Is Terraform?
Terraform is an open-source IaC tool released by HashiCorp in 2014. You define infrastructure in a declarative language called HCL (HashiCorp Configuration Language), and Terraform compares the current state against the desired configuration and applies only the difference.
In August 2024 HashiCorp was acquired by IBM, and Terraform's license changed to the BSL (Business Source License). The community's response was OpenTofu, under the Linux Foundation. Most of the commands covered in this article work identically in OpenTofu.
2.2 Terraform's Architecture
Terraform's core architecture consists of three pieces: Core + Providers + State.
┌──────────────────────────────────────────────────────────────┐
│ Terraform Architecture │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ .tf Files │ HCL Configuration │
│ │ (desired │ │
│ │ state) │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Terraform Core │ │
│ │ │ │
│ │ ┌───────────┐ ┌──────────────┐ │ │
│ │ │ Resource │ │ Dependency │ │ │
│ │ │ Graph │ │ Resolution │ │ │
│ │ └───────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌───────────┐ ┌──────────────┐ │ │
│ │ │ Plan │ │ Apply │ │ │
│ │ │ Engine │ │ Engine │ │ │
│ │ └───────────┘ └──────────────┘ │ │
│ └─────────┬───────────────┬────────────────┘ │
│ │ │ │
│ ┌──────▼──────┐ ┌─────▼──────────┐ │
│ │ Providers │ │ State File │ │
│ │ │ │ │ │
│ │ - AWS │ │ terraform.tfstate│ │
│ │ - Azure │ │ (JSON) │ │
│ │ - GCP │ │ │ │
│ │ - Kubernetes │ │ Local / Remote │ │
│ │ - 3000+ │ │ (S3, GCS, etc.) │ │
│ └──────────────┘ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
- Terraform Core: parses HCL, builds the dependency graph, and runs the plan/apply engine
- Providers: plugins that abstract each cloud/service API (AWS, Azure, GCP, Kubernetes, and more than 3,000 others)
- State: a JSON file that tracks the current state of the infrastructure
2.3 Terraform vs OpenTofu vs Pulumi
| Item | Terraform | OpenTofu | Pulumi |
|---|---|---|---|
| License | BSL 1.1 | MPL 2.0 (OSS) | Apache 2.0 |
| Language | HCL | HCL | Python/Go/TS/C# |
| State management | terraform.tfstate | terraform.tfstate | Pulumi Cloud |
| Provider ecosystem | 3,000+ | Terraform compatible | 100+ |
| Maintained by | HashiCorp (IBM) | Linux Foundation | Pulumi Inc. |
| CLI command | terraform | tofu | pulumi |
3. Installing Terraform and Setting Up the Environment
3.1 Version Management with tfenv
Projects often need different Terraform versions, so using tfenv (Terraform Version Manager) is strongly recommended.
# macOS (Homebrew)
brew install tfenv
# Linux (Git Clone)
git clone https://github.com/tfutils/tfenv.git ~/.tfenv
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrc
# List the available versions
tfenv list-remote
# Install a specific version
tfenv install 1.9.8
tfenv install 1.10.3
# Set the global default version
tfenv use 1.10.3
# Pin the version per project (the .terraform-version file)
echo "1.9.8" > .terraform-version
# List the installed versions
tfenv list
# Check the current version
terraform version
3.2 Installing Directly
# macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Linux (APT)
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
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
# Check the version
terraform version
# Terraform v1.10.3
# on darwin_arm64
3.3 Autocompletion and Editor Setup
# Bash/Zsh autocompletion
terraform -install-autocomplete
# VS Code extension
# - HashiCorp Terraform (official)
# - Terraform Autocomplete
4. The Core Terraform Workflow
Terraform's core workflow has three stages: Write → Plan → Apply. Let us look in detail at the commands that support them.
4.1 terraform init — Initializing the Project
terraform init is the first command in a Terraform project. It downloads provider plugins, downloads modules, and initializes the backend.
# Basic initialization
terraform init
# Main flags
terraform init -upgrade # Upgrade providers/modules to the newest allowed version
terraform init -reconfigure # Reconfigure the backend (ignoring existing state)
terraform init -migrate-state # Migrate state when the backend changes
terraform init -backend=false # Skip backend initialization (for validation)
terraform init -get=false # Skip module downloads
terraform init -input=false # Disable interactive input (for CI/CD)
terraform init -no-color # Disable colored output (for log parsing)
terraform init -lockfile=readonly # Forbid changes to .terraform.lock.hcl (for CI)
# Pass backend settings from the CLI (useful in CI/CD)
terraform init \
-backend-config="bucket=my-tf-state" \
-backend-config="key=prod/terraform.tfstate" \
-backend-config="region=ap-northeast-2" \
-backend-config="dynamodb_table=tf-lock"
The files and directories created after running terraform init:
.terraform/ # Provider plugins and module cache
.terraform.lock.hcl # Provider version lock file (commit this)
4.2 terraform validate — Validating the Syntax
# Check syntactic validity (available after init)
terraform validate
# JSON output (for CI/CD pipelines)
terraform validate -json
# Example output (success)
# Success! The configuration is valid.
# Example output (failure, JSON)
# {
# "valid": false,
# "error_count": 1,
# "diagnostics": [
# {
# "severity": "error",
# "summary": "Unsupported argument",
# "detail": "An argument named \"vps_id\" is not expected here. Did you mean \"vpc_id\"?"
# }
# ]
# }
4.3 terraform fmt — Formatting the Code
# Format the .tf files in the current directory
terraform fmt
# Format every subdirectory recursively
terraform fmt -recursive
# Show only the files that need changes (for a CI check)
terraform fmt -check
# diff output
terraform fmt -diff
# Using it in a CI/CD pipeline
terraform fmt -check -recursive -diff
# Exit code 0: no formatting changes
# Exit code 3: formatting changes needed
4.4 terraform plan — The Execution Plan
terraform plan is one of Terraform's most important commands. It compares the current state against the configuration and shows you in advance what will change. It makes no change to the real infrastructure.
# A basic plan
terraform plan
# Main flags
terraform plan -out=tfplan # Save the plan to a file (used by apply)
terraform plan -destroy # Review a destroy plan
terraform plan -target=aws_instance.web # Plan only a specific resource
terraform plan -var="instance_type=t3.large" # Pass a variable
terraform plan -var-file="prod.tfvars" # Specify a variable file
terraform plan -refresh=false # Skip refreshing state (faster)
terraform plan -parallelism=20 # Number of concurrent operations (default: 10)
terraform plan -compact-warnings # Condense warning messages
terraform plan -no-color # Disable colored output
terraform plan -input=false # Disable interactive input
terraform plan -json # JSON output (for automation)
terraform plan -detailed-exitcode # Detailed exit codes
# Exit code 0: no changes
# Exit code 1: an error occurred
# Exit code 2: there are changes
# The recommended pattern for a CI/CD pipeline
terraform plan -out=tfplan -input=false -no-color -detailed-exitcode
How to read the symbols in the plan output:
# + create (create a resource)
# - destroy (destroy a resource)
# ~ update (modify a resource, in place)
# -/+ replace (destroy then recreate a resource)
# <= read (read a data source)
4.5 terraform apply — Applying the Changes
# A basic apply (plan, then a confirmation prompt)
terraform apply
# Apply from a saved plan file (no confirmation prompt)
terraform apply tfplan
# Auto-approve (for CI/CD; use with care!)
terraform apply -auto-approve
# Main flags
terraform apply -target=aws_instance.web # Apply only a specific resource
terraform apply -var="instance_type=t3.large" # Pass a variable
terraform apply -var-file="prod.tfvars" # Specify a variable file
terraform apply -parallelism=20 # Number of concurrent operations
terraform apply -refresh=false # Skip refreshing state
terraform apply -replace=aws_instance.web # Force recreation of a resource (replaces taint)
terraform apply -lock=false # Disable state locking (not recommended)
terraform apply -lock-timeout=5m # How long to wait for the state lock
# A safe CI/CD pattern (plan → save → apply)
terraform plan -out=tfplan -input=false
# ... review ...
terraform apply tfplan
4.6 terraform destroy — Destroying the Infrastructure
# Destroy every resource (with a confirmation prompt)
terraform destroy
# Auto-approve
terraform destroy -auto-approve
# Destroy only a specific resource
terraform destroy -target=aws_instance.web
# Specify a variable file
terraform destroy -var-file="prod.tfvars"
# Review the destroy plan first
terraform plan -destroy
4.7 terraform output — Reading Output Values
# Show every output value
terraform output
# A specific output value
terraform output vpc_id
# The raw value (no quotes, for scripts)
terraform output -raw vpc_id
# JSON output
terraform output -json
# Using it from another Terraform project or from Ansible
VPC_ID=$(terraform output -raw vpc_id)
echo "VPC ID: $VPC_ID"
5. Terraform State Management
5.1 What Is State?
Terraform state (terraform.tfstate) is a JSON file recording the current state of the infrastructure Terraform manages. Terraform uses it to work out the difference between the configuration (the desired state) and the real infrastructure (the current state).
The state file contains resource IDs, attribute values, and metadata, and sensitive information such as passwords and access keys may be stored in plaintext, so you must use an encrypted remote backend (S3 + KMS, for example).
5.2 Configuring a Remote Backend
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/network/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
dynamodb_table = "terraform-lock" # DynamoDB table for state locking
kms_key_id = "alias/terraform" # KMS encryption key
}
}
5.3 The terraform state Commands
# ── List the resources ──
terraform state list
# aws_vpc.main
# aws_subnet.public[0]
# aws_subnet.public[1]
# aws_instance.web
# aws_db_instance.main
# Filtering
terraform state list aws_subnet.*
terraform state list module.network
# ── Show resource details ──
terraform state show aws_instance.web
# resource "aws_instance" "web" {
# ami = "ami-0c55b159cbfafe1f0"
# arn = "arn:aws:ec2:ap-northeast-2:123456789:instance/i-0abc123def456"
# instance_type = "t3.medium"
# private_ip = "10.0.1.50"
# public_ip = "54.180.xxx.xxx"
# ...
# }
# ── Move a resource (rename / move into a module) ──
# Rename a resource (the code has to change too)
terraform state mv aws_instance.web aws_instance.app_server
# Move it into a module
terraform state mv aws_vpc.main module.network.aws_vpc.main
# Move it into a different state file
terraform state mv -state-out=other.tfstate aws_instance.web aws_instance.web
# Dry run (check without making the change)
terraform state mv -dry-run aws_instance.web aws_instance.app
# ── Remove a resource from state (the real infrastructure stays) ──
terraform state rm aws_instance.web
# Terraform no longer manages this resource
# The real EC2 instance is not deleted
# ── Download the remote state ──
terraform state pull > terraform.tfstate.backup
# ── Upload local state to the remote ──
terraform state push terraform.tfstate
# Force the upload (ignores a serial number clash - dangerous!)
terraform state push -force terraform.tfstate
# ── Release a state lock ──
# When an abnormal exit left the lock in place
terraform force-unlock LOCK_ID
# LOCK_ID is shown in the error message
# Force the release without a confirmation prompt
terraform force-unlock -force LOCK_ID
5.4 terraform import — Bringing In Existing Resources
To bring a resource you created by hand under Terraform's management, use import.
# The traditional CLI import (before Terraform 1.5)
terraform import aws_instance.web i-0abc123def456
terraform import aws_vpc.main vpc-0abc123def
terraform import 'aws_subnet.public[0]' subnet-0abc123def
terraform import module.network.aws_vpc.main vpc-0abc123def
The import block in Terraform 1.5+ (declarative import, recommended):
# import.tf
import {
to = aws_instance.web
id = "i-0abc123def456"
}
import {
to = aws_vpc.main
id = "vpc-0abc123def"
}
# A plan based on the import block (generates the code automatically)
terraform plan -generate-config-out=generated.tf
# Review the generated code, then apply
terraform apply
6. Terraform Workspace
Workspaces are useful when you manage several environments (dev/staging/prod) from the same configuration. Each workspace has its own state file.
# ── List the workspaces ──
terraform workspace list
# * default
# dev
# staging
# prod
# ── Create a new workspace ──
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
# ── Switch workspaces ──
terraform workspace select prod
# ── Show the current workspace ──
terraform workspace show
# prod
# ── Delete a workspace (only if its state is empty) ──
terraform workspace delete dev
# Force the deletion (even when state remains)
terraform workspace delete -force dev
A pattern for using workspaces from HCL:
# Branch the instance type by environment
locals {
instance_type = {
dev = "t3.micro"
staging = "t3.small"
prod = "t3.large"
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = local.instance_type[terraform.workspace]
tags = {
Name = "app-${terraform.workspace}"
Environment = terraform.workspace
}
}
7. Managing Terraform Modules
7.1 Module Structure
modules/
├── network/
│ ├── main.tf # VPC, Subnet, IGW, NAT
│ ├── variables.tf # Input variables
│ ├── outputs.tf # Output values
│ └── README.md
├── compute/
│ ├── main.tf # EC2, ASG, ALB
│ ├── variables.tf
│ └── outputs.tf
└── database/
├── main.tf # RDS, ElastiCache
├── variables.tf
└── outputs.tf
7.2 Module Source Types
# A local module
module "network" {
source = "./modules/network"
}
# Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.16.0"
}
# GitHub
module "vpc" {
source = "github.com/terraform-aws-modules/terraform-aws-vpc?ref=v5.16.0"
}
# S3 Bucket
module "vpc" {
source = "s3::https://s3-ap-northeast-2.amazonaws.com/my-modules/vpc.zip"
}
# Git (SSH)
module "vpc" {
source = "git::ssh://git@github.com/myorg/modules.git//network?ref=v1.0.0"
}
7.3 Module-Related Commands
# Download/update the modules
terraform init -upgrade
# Show the providers a module uses
terraform providers
# Update the provider lock file (covering several platforms)
terraform providers lock \
-platform=linux_amd64 \
-platform=darwin_arm64
# Provider mirror (air-gapped environment)
terraform providers mirror /path/to/mirror
8. Advanced Terraform Features
8.1 terraform console — The Interactive Console
# Evaluate expressions interactively
terraform console
# Example usage
> var.instance_type
"t3.medium"
> length(var.subnet_ids)
3
> cidrsubnet("10.0.0.0/16", 8, 1)
"10.0.1.0/24"
> formatdate("YYYY-MM-DD", timestamp())
"2026-03-01"
> [for s in var.subnet_ids : upper(s)]
["SUBNET-AAA", "SUBNET-BBB", "SUBNET-CCC"]
# Exit: Ctrl+D or exit
8.2 terraform graph — The Dependency Graph
# Print the dependency graph in DOT format
terraform graph
# Render an image with Graphviz
terraform graph | dot -Tpng > graph.png
terraform graph | dot -Tsvg > graph.svg
# A plan-based graph
terraform graph -type=plan
# A graph centered on a specific resource
terraform graph -draw-cycles
8.3 Other Utility Commands
# Show the provider tree used by the current configuration
terraform providers
# Print the provider schema as JSON
terraform providers schema -json
# Check the Terraform version
terraform version
# JSON output
terraform version -json
# Show the location of the Terraform configuration file
terraform -help
# Help for a specific command
terraform plan -help
9. An HCL Syntax Cheat Sheet
9.1 Variables (Input Variables)
# variables.tf
# Basic types
variable "region" {
description = "AWS Region"
type = string
default = "ap-northeast-2"
}
variable "instance_count" {
description = "Number of instances"
type = number
default = 2
}
variable "enable_monitoring" {
description = "Enable CloudWatch monitoring"
type = bool
default = true
}
# Composite types - List
variable "availability_zones" {
type = list(string)
default = ["ap-northeast-2a", "ap-northeast-2c"]
}
# Composite types - Map
variable "instance_types" {
type = map(string)
default = {
dev = "t3.micro"
prod = "t3.large"
}
}
# Composite types - Object
variable "database_config" {
type = object({
engine = string
engine_version = string
instance_class = string
multi_az = bool
storage_gb = number
})
default = {
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.medium"
multi_az = true
storage_gb = 100
}
}
# A sensitive variable
variable "db_password" {
description = "Database master password"
type = string
sensitive = true # Masked in plan/apply output
}
# Validation Rule
variable "instance_type" {
type = string
validation {
condition = can(regex("^t3\\.", var.instance_type))
error_message = "Instance type must start with t3."
}
}
# Nullable
variable "override_name" {
type = string
default = null
nullable = true
}
Ways to pass variable values (highest precedence first):
# 1. The CLI -var flag (highest precedence)
terraform apply -var="region=us-east-1"
# 2. The -var-file flag
terraform apply -var-file="prod.tfvars"
# 3. *.auto.tfvars (loaded automatically)
# terraform.tfvars, *.auto.tfvars
# 4. Environment variables (the TF_VAR_ prefix)
export TF_VAR_region="us-east-1"
export TF_VAR_db_password="SuperSecret123!"
# 5. The default value
9.2 Locals (Local Variables)
locals {
project_name = "my-app"
environment = terraform.workspace
common_tags = {
Project = local.project_name
Environment = local.environment
ManagedBy = "terraform"
Team = "platform"
}
# A conditional value
is_prod = local.environment == "prod"
# A computed value
name_prefix = "${local.project_name}-${local.environment}"
}
resource "aws_instance" "app" {
# ...
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-app"
})
}
9.3 Outputs (Output Values)
# outputs.tf
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = aws_subnet.public[*].id
}
output "db_endpoint" {
description = "RDS endpoint"
value = aws_db_instance.main.endpoint
sensitive = true # Masks a sensitive output value
}
# Referencing it from another module
# module.network.vpc_id
9.4 count and for_each
# count — create N copies of the same resource
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = var.availability_zones[count.index]
tags = {
Name = "public-subnet-${count.index}"
}
}
# References: aws_subnet.public[0], aws_subnet.public[1]
# for_each — iterate over a map or set (recommended)
resource "aws_iam_user" "users" {
for_each = toset(["alice", "bob", "charlie"])
name = each.value
}
# Map-based for_each
variable "instances" {
default = {
web = { type = "t3.small", az = "ap-northeast-2a" }
api = { type = "t3.medium", az = "ap-northeast-2c" }
worker = { type = "t3.large", az = "ap-northeast-2a" }
}
}
resource "aws_instance" "servers" {
for_each = var.instances
ami = data.aws_ami.ubuntu.id
instance_type = each.value.type
availability_zone = each.value.az
tags = {
Name = "${each.key}-server"
}
}
# References: aws_instance.servers["web"], aws_instance.servers["api"]
9.5 dynamic Block
# Generate security group rules dynamically
variable "ingress_rules" {
default = [
{ port = 80, cidr = "0.0.0.0/0", description = "HTTP" },
{ port = 443, cidr = "0.0.0.0/0", description = "HTTPS" },
{ port = 22, cidr = "10.0.0.0/8", description = "SSH (internal)" },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
description = "Web server security group"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = [ingress.value.cidr]
description = ingress.value.description
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
9.6 The lifecycle Meta-Argument
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
lifecycle {
# Create the new resource before destroying the old one (minimizes downtime)
create_before_destroy = true
# Ignore changes to specific attributes (tags edited outside Terraform, etc.)
ignore_changes = [
tags["LastModified"],
ami,
]
# Prevent deletion (so destroy cannot happen by accident)
prevent_destroy = true
# Pre/post conditions
precondition {
condition = var.instance_type != "t3.nano"
error_message = "t3.nano is too small for this application."
}
postcondition {
condition = self.public_ip != ""
error_message = "Instance must have a public IP."
}
# Replacement trigger (the resource is recreated when the value changes)
replace_triggered_by = [
aws_ami.app_ami.id
]
}
}
10. A Practical Terraform Example — AWS VPC + EC2 + RDS
10.1 Project Structure
terraform-aws-project/
├── main.tf # Provider and backend configuration
├── variables.tf # Input variable definitions
├── outputs.tf # Output value definitions
├── terraform.tfvars # Variable values (gitignored)
├── network.tf # VPC, Subnet, IGW, NAT, Route Table
├── compute.tf # EC2, Security Group, Key Pair
├── database.tf # RDS, Subnet Group
├── data.tf # Data sources (AMI lookups, etc.)
└── versions.tf # Provider/Terraform version constraints
10.2 The Full Code
# versions.tf
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.80"
}
}
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
dynamodb_table = "terraform-lock"
}
}
# main.tf
provider "aws" {
region = var.region
default_tags {
tags = {
Project = var.project_name
Environment = terraform.workspace
ManagedBy = "terraform"
}
}
}
# variables.tf
variable "region" {
type = string
default = "ap-northeast-2"
}
variable "project_name" {
type = string
default = "myapp"
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "azs" {
type = list(string)
default = ["ap-northeast-2a", "ap-northeast-2c"]
}
variable "db_password" {
type = string
sensitive = true
}
# data.tf
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}
# network.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.project_name}-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.project_name}-igw" }
}
resource "aws_subnet" "public" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.project_name}-public-${count.index}" }
}
resource "aws_subnet" "private" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = var.azs[count.index]
tags = { Name = "${var.project_name}-private-${count.index}" }
}
resource "aws_eip" "nat" {
domain = "vpc"
tags = { Name = "${var.project_name}-nat-eip" }
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
tags = { Name = "${var.project_name}-nat" }
depends_on = [aws_internet_gateway.main]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "${var.project_name}-public-rt" }
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = { Name = "${var.project_name}-private-rt" }
}
resource "aws_route_table_association" "public" {
count = length(var.azs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = length(var.azs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
# compute.tf
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Security group for web servers"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP"
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS"
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
description = "SSH internal"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.project_name}-web-sg" }
}
resource "aws_instance" "web" {
count = 2
ami = data.aws_ami.ubuntu.id
instance_type = "t3.small"
subnet_id = aws_subnet.public[count.index % length(var.azs)].id
vpc_security_group_ids = [aws_security_group.web.id]
root_block_device {
volume_size = 30
volume_type = "gp3"
encrypted = true
}
tags = { Name = "${var.project_name}-web-${count.index}" }
lifecycle {
create_before_destroy = true
ignore_changes = [ami]
}
}
# database.tf
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Security group for RDS"
vpc_id = aws_vpc.main.id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.web.id]
description = "MySQL from web servers"
}
tags = { Name = "${var.project_name}-db-sg" }
}
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = aws_subnet.private[*].id
tags = { Name = "${var.project_name}-db-subnet-group" }
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-mysql"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.medium"
allocated_storage = 100
storage_type = "gp3"
storage_encrypted = true
db_name = "myapp"
username = "admin"
password = var.db_password
multi_az = true
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
skip_final_snapshot = false
final_snapshot_identifier = "${var.project_name}-final-snapshot"
backup_retention_period = 7
tags = { Name = "${var.project_name}-mysql" }
lifecycle {
prevent_destroy = true
}
}
# outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "web_instance_ips" {
value = aws_instance.web[*].public_ip
}
output "rds_endpoint" {
value = aws_db_instance.main.endpoint
sensitive = true
}
Part 2: The Complete Ansible Guide
11. Introducing Ansible and Its Architecture
11.1 What Is Ansible?
Ansible is an open-source automation tool maintained by Red Hat, created by Michael DeHaan in 2012. It handles configuration management, application deployment, and task automation in a single tool.
Ansible's core philosophy:
- Agentless: no agent has to be installed on the target servers (it works over SSH/WinRM)
- Idempotent: running the same playbook several times produces the same result
- YAML-based: written in YAML rather than a programming language, which lowers the barrier to entry
- A push model: work is pushed from the control node to the managed nodes
11.2 Ansible's Architecture
┌──────────────────────────────────────────────────────────────────┐
│ Ansible Architecture │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Control Node │ │
│ │ │ │
│ │ ┌────────────┐ ┌──────────┐ ┌────────────┐ │ │
│ │ │ Playbook │ │ Inventory │ │ Modules │ │ │
│ │ │ (.yml) │ │ (hosts) │ │ (2,500+) │ │ │
│ │ └─────┬──────┘ └────┬─────┘ └──────┬─────┘ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ Ansible Engine │ │ │
│ │ │ │ │ │
│ │ │ - Task Execution │ │ │
│ │ │ - Variable Resolution │ │ │
│ │ │ - Connection Management (SSH/WinRM) │ │ │
│ │ │ - Fact Gathering │ │ │
│ │ └─────────────┬─────────────────────────────┘ │ │
│ └────────────────┼─────────────────────────────────┘ │
│ │ │
│ SSH / WinRM (No Agent!) │
│ │ │
│ ┌─────────────┼──────────────────────────────┐ │
│ │ ▼ │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │ Web1 │ │ Web2 │ │ DB1 │ │ DB2 │ │ │
│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
│ │ Managed Nodes │ │
│ └────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
- Control node: the node where Ansible is installed and runs (Linux/macOS; Windows needs WSL)
- Managed node: a target server Ansible manages (all it needs is SSH access)
- Inventory: the list of hosts to manage
- Module: the unit of code that performs the actual work (2,500+ built-in modules)
- Playbook: a YAML file that defines the order of the work
12. Installing Ansible
# ── Install with pip (recommended) ──
pip install ansible
# Install a specific version
pip install ansible==10.6.0
# Install ansible-core only (a minimal install)
pip install ansible-core
# ── macOS (Homebrew) ──
brew install ansible
# ── Ubuntu/Debian ──
sudo apt update
sudo apt install ansible
# ── RHEL/CentOS/Fedora ──
sudo dnf install ansible
# ── Check the version ──
ansible --version
# ansible [core 2.17.7]
# config file = /etc/ansible/ansible.cfg
# configured module search path = ['~/.ansible/plugins/modules']
# ansible python module location = /usr/lib/python3.12/site-packages/ansible
# python version = 3.12.8
# ── Install ansible-navigator (TUI, supports execution environments) ──
pip install ansible-navigator
13. The Ansible Inventory
13.1 A Static Inventory (INI Format)
# inventory/hosts.ini
# Individual hosts
web1.example.com
web2.example.com
# Group definitions
[webservers]
web1.example.com ansible_host=10.0.1.10
web2.example.com ansible_host=10.0.1.11
[dbservers]
db1.example.com ansible_host=10.0.2.10 ansible_port=2222
db2.example.com ansible_host=10.0.2.11
[monitoring]
grafana.example.com
# Group variables
[webservers:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/web_key.pem
http_port=80
[dbservers:vars]
ansible_user=ec2-user
ansible_ssh_private_key_file=~/.ssh/db_key.pem
# Groups of groups (children)
[production:children]
webservers
dbservers
monitoring
[production:vars]
env=production
# Host range patterns
[loadbalancers]
lb[01:03].example.com # lb01, lb02, lb03
13.2 A Static Inventory (YAML Format)
# inventory/hosts.yml
all:
children:
production:
children:
webservers:
hosts:
web1.example.com:
ansible_host: 10.0.1.10
web2.example.com:
ansible_host: 10.0.1.11
vars:
ansible_user: ubuntu
ansible_ssh_private_key_file: ~/.ssh/web_key.pem
http_port: 80
dbservers:
hosts:
db1.example.com:
ansible_host: 10.0.2.10
db2.example.com:
ansible_host: 10.0.2.11
vars:
ansible_user: ec2-user
vars:
env: production
staging:
children:
webservers_stg:
hosts:
stg-web1.example.com:
ansible_host: 10.1.1.10
13.3 A Dynamic Inventory
A dynamic inventory pulls the host list from a cloud API in real time.
# inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
- ap-northeast-2
keyed_groups:
- key: tags.Environment
prefix: env
- key: instance_type
prefix: type
- key: placement.availability_zone
prefix: az
filters:
tag:ManagedBy: ansible
instance-state-name: running
compose:
ansible_host: private_ip_address
# Test the dynamic inventory
ansible-inventory -i inventory/aws_ec2.yml --list
ansible-inventory -i inventory/aws_ec2.yml --graph
13.4 Inventory-Related Commands
# Show the hosts in the inventory
ansible-inventory -i inventory/hosts.yml --list
ansible-inventory -i inventory/hosts.yml --graph
# Show only a specific group
ansible-inventory -i inventory/hosts.yml --graph webservers
# Show a host's variables
ansible-inventory -i inventory/hosts.yml --host web1.example.com
# JSON output
ansible-inventory -i inventory/hosts.yml --list --yaml
14. Ansible Ad-Hoc Commands
An ad-hoc command is a one-off command you run in a single line, without a playbook. It is useful for quick checks and simple tasks.
14.1 Basic Syntax
ansible [host pattern] -i [inventory] -m [module] -a "[arguments]" [options]
14.2 Examples by Module
# ── ping: check connectivity ──
ansible all -i inventory/hosts.yml -m ping
ansible webservers -i inventory/hosts.yml -m ping
# ── command: run a command (the default module, no shell features) ──
ansible webservers -m command -a "uptime"
ansible webservers -m command -a "df -h"
ansible webservers -m command -a "free -m"
# ── shell: run a shell command (pipes and redirects supported) ──
ansible webservers -m shell -a "ps aux | grep nginx | wc -l"
ansible webservers -m shell -a "cat /etc/os-release | head -5"
ansible dbservers -m shell -a "mysql -e 'SHOW DATABASES;'"
# ── copy: copy a file ──
ansible webservers -m copy -a "src=./app.conf dest=/etc/nginx/conf.d/app.conf owner=root group=root mode=0644"
# ── file: manage files and directories ──
ansible webservers -m file -a "path=/opt/app state=directory owner=www-data mode=0755"
ansible webservers -m file -a "path=/tmp/old_file state=absent"
ansible webservers -m file -a "src=/etc/nginx/sites-available/app dest=/etc/nginx/sites-enabled/app state=link"
# ── apt: package management (Debian/Ubuntu) ──
ansible webservers -m apt -a "name=nginx state=present update_cache=yes" --become
ansible webservers -m apt -a "name=nginx state=latest" --become
ansible webservers -m apt -a "name=nginx state=absent" --become
ansible webservers -m apt -a "upgrade=dist" --become
# ── yum/dnf: package management (RHEL/CentOS) ──
ansible dbservers -m dnf -a "name=mysql-server state=present" --become
# ── service/systemd: service management ──
ansible webservers -m service -a "name=nginx state=started enabled=yes" --become
ansible webservers -m service -a "name=nginx state=restarted" --become
ansible webservers -m service -a "name=nginx state=stopped" --become
ansible webservers -m systemd -a "name=nginx state=reloaded daemon_reload=yes" --become
# ── user: user management ──
ansible all -m user -a "name=deploy state=present groups=sudo shell=/bin/bash" --become
ansible all -m user -a "name=olduser state=absent remove=yes" --become
# ── setup: gather facts (system information) ──
ansible webservers -m setup
ansible webservers -m setup -a "filter=ansible_os_family"
ansible webservers -m setup -a "filter=ansible_memtotal_mb"
ansible webservers -m setup -a "filter=ansible_distribution*"
# ── lineinfile: manage a line inside a file ──
ansible webservers -m lineinfile -a "path=/etc/ssh/sshd_config regexp='^PermitRootLogin' line='PermitRootLogin no'" --become
# ── cron: manage cron jobs ──
ansible webservers -m cron -a "name='log cleanup' minute='0' hour='3' job='find /var/log -name \"*.gz\" -mtime +30 -delete'"
# ── get_url: download a file from a URL ──
ansible webservers -m get_url -a "url=https://example.com/app.tar.gz dest=/tmp/app.tar.gz"
# ── git: clone/pull a Git repository ──
ansible webservers -m git -a "repo=https://github.com/myorg/myapp.git dest=/opt/myapp version=main"
14.3 Main Ad-Hoc Options
# Main options
-i inventory/hosts.yml # Specify the inventory file
-m module_name # Specify the module (default: command)
-a "arguments" # Module arguments
--become (-b) # Escalate to sudo
--become-user root # The user to escalate to
--become-method sudo # The escalation method
-u ubuntu # The SSH user
--private-key ~/.ssh/key # The SSH key
-f 10 # Number of parallel executions (default: 5)
--limit web1 # Run against specific hosts only
-v / -vv / -vvv / -vvvv # Verbosity level
--check # Dry run (no real changes)
--diff # Show a diff of the changes
-o # One-line output (summary)
--ask-pass (-k) # Prompt for the SSH password
--ask-become-pass (-K) # Prompt for the sudo password
15. Ansible Playbook
15.1 ansible-playbook Command Options
# Basic run
ansible-playbook -i inventory/hosts.yml playbook.yml
# Main options
ansible-playbook playbook.yml \
-i inventory/hosts.yml \ # Inventory
--limit webservers \ # Restrict the target hosts
--tags "nginx,ssl" \ # Run only specific tags
--skip-tags "debug" \ # Skip specific tags
-e "env=prod version=2.1" \ # Pass extra variables
-e @vars/prod.yml \ # Pass a variable file
--check \ # Dry-run
--diff \ # Diff of the changes
--start-at-task "Install Nginx" \ # Start from a specific task
--step \ # Confirmation prompt per task
--list-tasks \ # List the tasks only
--list-tags \ # List the tags only
--list-hosts \ # List the target hosts only
-f 20 \ # Number of parallel executions
--become \ # sudo
--vault-password-file .vault_pass \ # Vault password file
--ask-vault-pass \ # Prompt for the Vault password
-v # Verbose output
# Syntax check
ansible-playbook playbook.yml --syntax-check
15.2 The Basic Structure of a Playbook
# site.yml
---
- name: Configure web servers
hosts: webservers
become: yes
gather_facts: yes
vars:
http_port: 80
app_version: '2.1.0'
pre_tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
tasks:
- name: Install Nginx
apt:
name: nginx
state: present
tags: [nginx]
- name: Deploy Nginx config
template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
notify: Restart Nginx
tags: [nginx, config]
- name: Deploy application
git:
repo: 'https://github.com/myorg/myapp.git'
dest: /opt/myapp
version: '{{ app_version }}'
tags: [deploy]
- name: Ensure Nginx is running
service:
name: nginx
state: started
enabled: yes
tags: [nginx]
post_tasks:
- name: Verify HTTP response
uri:
url: 'http://localhost:{{ http_port }}'
status_code: 200
register: result
retries: 3
delay: 5
until: result.status == 200
handlers:
- name: Restart Nginx
service:
name: nginx
state: restarted
15.3 Conditionals (when)
tasks:
# A basic condition
- name: Install Apache (Debian)
apt:
name: apache2
state: present
when: ansible_os_family == "Debian"
- name: Install httpd (RedHat)
dnf:
name: httpd
state: present
when: ansible_os_family == "RedHat"
# A compound condition
- name: Configure for production
template:
src: prod.conf.j2
dest: /etc/app/app.conf
when:
- env == "prod"
- ansible_memtotal_mb >= 4096
# An OR condition
- name: Install on Debian or Ubuntu
apt:
name: curl
state: present
when: ansible_distribution == "Debian" or ansible_distribution == "Ubuntu"
# Whether a variable exists
- name: Configure custom DNS
template:
src: resolv.conf.j2
dest: /etc/resolv.conf
when: custom_dns is defined
# Based on the result of an earlier task
- name: Check if config exists
stat:
path: /etc/app/app.conf
register: config_file
- name: Create default config
template:
src: default.conf.j2
dest: /etc/app/app.conf
when: not config_file.stat.exists
15.4 Loops (loop)
tasks:
# A basic loop
- name: Install packages
apt:
name: '{{ item }}'
state: present
loop:
- nginx
- python3
- git
- curl
- htop
# A more efficient approach (install them all at once)
- name: Install packages (optimized)
apt:
name:
- nginx
- python3
- git
state: present
# Dict loop
- name: Create users
user:
name: '{{ item.name }}'
groups: '{{ item.groups }}'
shell: '{{ item.shell }}'
state: present
loop:
- { name: 'alice', groups: 'sudo', shell: '/bin/bash' }
- { name: 'bob', groups: 'developers', shell: '/bin/zsh' }
- { name: 'charlie', groups: 'developers', shell: '/bin/bash' }
# with_fileglob (file globbing)
- name: Copy all config files
copy:
src: '{{ item }}'
dest: /etc/app/conf.d/
with_fileglob:
- 'files/configs/*.conf'
# loop_control
- name: Create directories with index
file:
path: '/opt/app/data-{{ idx }}'
state: directory
loop:
- logs
- cache
- uploads
loop_control:
index_var: idx
label: '{{ item }}' # The label to show in the output (hides sensitive data)
15.5 Handlers
tasks:
- name: Update Nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify:
- Validate Nginx config
- Restart Nginx
- name: Update SSL certificate
copy:
src: ssl/cert.pem
dest: /etc/ssl/certs/app.pem
notify: Restart Nginx
handlers:
# A handler runs only when notified, and only once no matter how many times it is notified
- name: Validate Nginx config
command: nginx -t
listen: 'Validate Nginx config'
- name: Restart Nginx
service:
name: nginx
state: restarted
listen: 'Restart Nginx'
15.6 Block (Error Handling)
tasks:
- name: Application deployment with rollback
block:
- name: Pull latest code
git:
repo: 'https://github.com/myorg/myapp.git'
dest: /opt/myapp
version: '{{ app_version }}'
- name: Install dependencies
pip:
requirements: /opt/myapp/requirements.txt
virtualenv: /opt/myapp/venv
- name: Run database migrations
command: /opt/myapp/venv/bin/python manage.py migrate
args:
chdir: /opt/myapp
- name: Restart application
systemd:
name: myapp
state: restarted
rescue:
- name: Rollback to previous version
git:
repo: 'https://github.com/myorg/myapp.git'
dest: /opt/myapp
version: '{{ previous_version }}'
- name: Restart application (rollback)
systemd:
name: myapp
state: restarted
- name: Send failure notification
slack:
token: '{{ slack_token }}'
channel: '#deploy'
msg: 'Deployment of {{ app_version }} FAILED. Rolled back to {{ previous_version }}.'
always:
- name: Clean up temp files
file:
path: /tmp/deploy_artifacts
state: absent
- name: Log deployment result
lineinfile:
path: /var/log/deployments.log
line: "{{ ansible_date_time.iso8601 }} - {{ app_version }} - {{ ansible_failed_task.name | default('SUCCESS') }}"
create: yes
16. Ansible Role
16.1 Role Structure
roles/
└── nginx/
├── tasks/
│ ├── main.yml # The main tasks
│ ├── install.yml # Installation tasks
│ └── configure.yml # Configuration tasks
├── handlers/
│ └── main.yml # Handlers
├── templates/
│ └── nginx.conf.j2 # Jinja2 template
├── files/
│ └── index.html # Static file
├── vars/
│ └── main.yml # Variables (high precedence)
├── defaults/
│ └── main.yml # Defaults (low precedence)
├── meta/
│ └── main.yml # Metadata and dependencies
├── tests/
│ ├── inventory
│ └── test.yml
└── README.md
16.2 The ansible-galaxy Command
# ── Managing roles ──
# Initialize a role (creates the directory structure)
ansible-galaxy role init roles/nginx
ansible-galaxy role init --init-path=./roles nginx
# Install a role from Galaxy
ansible-galaxy role install geerlingguy.nginx
ansible-galaxy role install geerlingguy.docker -p roles/
# Install a specific version
ansible-galaxy role install geerlingguy.nginx,3.1.0
# Install everything from requirements.yml
ansible-galaxy role install -r requirements.yml
# List the installed roles
ansible-galaxy role list
# Remove a role
ansible-galaxy role remove geerlingguy.nginx
# Search for roles
ansible-galaxy role search nginx --author geerlingguy
# Show role information
ansible-galaxy role info geerlingguy.nginx
# ── Managing collections ──
# Install a collection
ansible-galaxy collection install amazon.aws
ansible-galaxy collection install community.general
# Install everything from requirements.yml
ansible-galaxy collection install -r requirements.yml
# List the installed collections
ansible-galaxy collection list
# Build a collection
ansible-galaxy collection build
# Publish a collection
ansible-galaxy collection publish ./myns-mycoll-1.0.0.tar.gz
An example requirements.yml:
# requirements.yml
---
roles:
- name: geerlingguy.nginx
version: '3.1.0'
- name: geerlingguy.docker
version: '7.4.1'
- name: geerlingguy.certbot
- src: https://github.com/myorg/ansible-role-custom.git
scm: git
version: main
name: custom_role
collections:
- name: amazon.aws
version: '>=8.0.0'
- name: community.general
- name: ansible.posix
16.3 An Example of Using a Role
# site.yml
---
- name: Configure web servers
hosts: webservers
become: yes
roles:
# Basic usage
- nginx
# Passing variables
- role: nginx
vars:
nginx_worker_processes: 4
nginx_worker_connections: 2048
# Conditional execution
- role: certbot
when: enable_ssl | default(false)
# Specifying tags
- role: monitoring
tags: [monitoring, observability]
17. Ansible Vault
Ansible Vault is the feature that encrypts sensitive data such as passwords, API keys, and certificates.
17.1 The Vault Commands
# ── Create an encrypted file ──
ansible-vault create secrets.yml
# An editor opens, and the file is encrypted automatically on save
# Create it with a specified editor
EDITOR=nano ansible-vault create secrets.yml
# ── Edit an encrypted file ──
ansible-vault edit secrets.yml
# ── Encrypt an existing file ──
ansible-vault encrypt vars/prod_secrets.yml
# Encrypt several files at once
ansible-vault encrypt vars/secret1.yml vars/secret2.yml
# ── Decrypt an encrypted file ──
ansible-vault decrypt vars/prod_secrets.yml
# ── View the contents of an encrypted file (without decrypting it) ──
ansible-vault view secrets.yml
# ── Change the password ──
ansible-vault rekey secrets.yml
# ── Encrypt a string (inline) ──
ansible-vault encrypt_string 'SuperSecretPassword123!' --name 'db_password'
# Output:
# db_password: !vault |
# $ANSIBLE_VAULT;1.1;AES256
# 61636530396131653661313936353764...
ansible-vault encrypt_string --vault-password-file .vault_pass 'my_api_key_value' --name 'api_key'
# ── Ways to supply the Vault password ──
# 1. A prompt
ansible-playbook site.yml --ask-vault-pass
# 2. A file
ansible-playbook site.yml --vault-password-file .vault_pass
# 3. An environment variable
export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
ansible-playbook site.yml
# 4. A script (integrating with a password manager)
ansible-playbook site.yml --vault-password-file ./get_vault_pass.sh
# ── Using several Vault IDs (a different password per environment) ──
ansible-vault create --vault-id prod@prompt secrets_prod.yml
ansible-vault create --vault-id dev@.vault_pass_dev secrets_dev.yml
ansible-playbook site.yml \
--vault-id prod@prompt \
--vault-id dev@.vault_pass_dev
17.2 An Example of Using Vault
# vars/secrets.yml (encrypted)
---
db_password: 'SuperSecretPassword123!'
api_key: 'sk-1234567890abcdef'
ssl_private_key: |
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...
-----END PRIVATE KEY-----
# Using it in a playbook
- name: Deploy application
hosts: webservers
become: yes
vars_files:
- vars/defaults.yml
- vars/secrets.yml # A file encrypted with Vault
tasks:
- name: Configure database connection
template:
src: db_config.j2
dest: /etc/app/database.yml
mode: '0600'
18. Advanced Ansible Features
18.1 ansible-doc — Module Documentation
# View the module help
ansible-doc apt
ansible-doc copy
ansible-doc template
ansible-doc amazon.aws.ec2_instance
# List the modules
ansible-doc --list
ansible-doc --list | grep aws
# Short help (usage examples)
ansible-doc -s apt
ansible-doc -s copy
# Documentation by plugin type
ansible-doc -t callback -l # List the callback plugins
ansible-doc -t connection -l # List the connection plugins
ansible-doc -t inventory -l # List the inventory plugins
ansible-doc -t lookup -l # List the lookup plugins
18.2 ansible-navigator — A TUI-Based Tool
# Run a playbook in TUI mode
ansible-navigator run site.yml -i inventory/hosts.yml
# stdout mode (output similar to plain ansible-playbook)
ansible-navigator run site.yml -i inventory/hosts.yml -m stdout
# Browse the inventory
ansible-navigator inventory -i inventory/hosts.yml
# Browse the module documentation
ansible-navigator doc apt
# Browse collections
ansible-navigator collections
# Check the configuration
ansible-navigator config
18.3 ansible-lint — Code Quality
# Install it
pip install ansible-lint
# Lint a playbook
ansible-lint site.yml
# Lint a whole directory
ansible-lint
# Ignore specific rules
ansible-lint -x yaml[truthy]
# Fix automatically
ansible-lint --fix
18.4 ansible.cfg Configuration
# ansible.cfg
[defaults]
inventory = inventory/hosts.yml
remote_user = ubuntu
private_key_file = ~/.ssh/id_rsa
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
callback_whitelist = timer, profile_tasks
forks = 20
timeout = 30
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 86400
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no
control_path_dir = ~/.ansible/cp
Part 3: Integration, Cheat Sheets, and Troubleshooting
19. Combining Terraform and Ansible
19.1 The Integration Architecture
The most common pattern for connecting Terraform and Ansible is as follows.
┌─────────────────────────────────────────────────────────────┐
│ Terraform + Ansible Integration │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Provision the infrastructure with Terraform │
│ terraform apply │
│ │ │
│ ├── Create VPC, subnets, security groups │
│ ├── Create EC2 instances │
│ └── terraform output -json │
│ │ │
│ ▼ │
│ 2. Terraform Output → Ansible Dynamic Inventory │
│ terraform output -json > tf_output.json │
│ │ │
│ ▼ │
│ 3. Configuration management with Ansible │
│ ansible-playbook -i dynamic_inventory.py site.yml │
│ │ │
│ ├── Install packages │
│ ├── Configure the application │
│ └── Start the services │
│ │
└─────────────────────────────────────────────────────────────┘
19.2 Using Terraform Output from Ansible
# Terraform outputs.tf
output "web_server_ips" {
value = aws_instance.web[*].public_ip
}
output "db_endpoint" {
value = aws_db_instance.main.endpoint
sensitive = true
}
output "ssh_key_name" {
value = aws_key_pair.deployer.key_name
}
#!/usr/bin/env python3
# dynamic_inventory.py — a dynamic inventory built from Terraform output
import json
import subprocess
import sys
def get_terraform_output():
result = subprocess.run(
["terraform", "output", "-json"],
capture_output=True, text=True
)
return json.loads(result.stdout)
def main():
tf_output = get_terraform_output()
web_ips = tf_output["web_server_ips"]["value"]
inventory = {
"webservers": {
"hosts": web_ips,
"vars": {
"ansible_user": "ubuntu",
"ansible_ssh_private_key_file": "~/.ssh/deployer.pem",
"db_endpoint": tf_output["db_endpoint"]["value"]
}
},
"_meta": {
"hostvars": {}
}
}
print(json.dumps(inventory, indent=2))
if __name__ == "__main__":
main()
# Run it
chmod +x dynamic_inventory.py
ansible-playbook -i dynamic_inventory.py site.yml
19.3 Calling Ansible from a Terraform local-exec Provisioner
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.small"
key_name = aws_key_pair.deployer.key_name
# Run Ansible (use provisioners only as a last resort)
provisioner "local-exec" {
command = <<-EOT
sleep 30 # Wait for SSH to be ready
ANSIBLE_HOST_KEY_CHECKING=False \
ansible-playbook \
-i '${self.public_ip},' \
-u ubuntu \
--private-key ~/.ssh/deployer.pem \
-e "db_endpoint=${aws_db_instance.main.endpoint}" \
ansible/site.yml
EOT
}
depends_on = [aws_db_instance.main]
}
19.4 A Terraform + Ansible Automation Script
#!/bin/bash
# deploy.sh — deploy the whole infrastructure plus configuration
set -euo pipefail
echo "=== Step 1: Terraform Init ==="
terraform init -input=false
echo "=== Step 2: Terraform Plan ==="
terraform plan -out=tfplan -input=false
echo "=== Step 3: Terraform Apply ==="
terraform apply tfplan
echo "=== Step 4: Generate Ansible Inventory ==="
terraform output -json > tf_output.json
echo "=== Step 5: Wait for instances to be ready ==="
WEB_IPS=$(terraform output -json web_server_ips | jq -r '.[]')
for ip in $WEB_IPS; do
echo "Waiting for $ip..."
until ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 ubuntu@"$ip" true 2>/dev/null; do
sleep 5
done
echo "$ip is ready!"
done
echo "=== Step 6: Run Ansible Playbook ==="
ANSIBLE_HOST_KEY_CHECKING=False \
ansible-playbook \
-i ./dynamic_inventory.py \
--vault-password-file .vault_pass \
site.yml
echo "=== Deployment Complete ==="
terraform output
20. The Top 20 Terraform Command Cheat Sheet
# ── Initialization and validation ──
terraform init # 1. Initialize the project
terraform init -upgrade # 2. Upgrade providers/modules
terraform validate # 3. Validate the syntax
terraform fmt -recursive -check # 4. Check the formatting
# ── The core workflow ──
terraform plan -out=tfplan # 5. Save the execution plan
terraform apply tfplan # 6. Apply the plan
terraform apply -auto-approve # 7. Apply with auto-approve (CI/CD)
terraform destroy -auto-approve # 8. Destroy everything
# ── State management ──
terraform state list # 9. List the resources
terraform state show aws_instance.web # 10. Resource details
terraform state mv OLD NEW # 11. Move/rename a resource
terraform state rm RESOURCE # 12. Remove it from state
terraform state pull > backup.tfstate # 13. Back up the state
terraform force-unlock LOCK_ID # 14. Release the lock
# ── Import ──
terraform import RESOURCE ID # 15. Bring in an existing resource
terraform plan -generate-config-out=g.tf # 16. Generate import code automatically
# ── Workspace ──
terraform workspace new ENV # 17. Create a workspace
terraform workspace select ENV # 18. Switch workspaces
# ── Utilities ──
terraform output -json # 19. Output values (JSON)
terraform console # 20. Interactive console
21. The Top 20 Ansible Command Cheat Sheet
# ── Checking connectivity and gathering information ──
ansible all -m ping # 1. Ping every host
ansible all -m setup -a "filter=ansible_os*" # 2. Gather system information
# ── Ad-hoc commands ──
ansible web -m shell -a "uptime" # 3. Run a shell command
ansible web -m apt -a "name=nginx state=present" -b # 4. Install a package
ansible web -m service -a "name=nginx state=started" -b # 5. Start a service
ansible web -m copy -a "src=f dest=/etc/app/" -b # 6. Copy a file
ansible web -m user -a "name=deploy state=present" -b # 7. Create a user
# ── Running playbooks ──
ansible-playbook site.yml # 8. Run a playbook
ansible-playbook site.yml --check --diff # 9. Dry-run + diff
ansible-playbook site.yml --limit web1 # 10. Specific hosts only
ansible-playbook site.yml --tags deploy # 11. Specific tags only
ansible-playbook site.yml -e "env=prod" # 12. Pass a variable
ansible-playbook site.yml --syntax-check # 13. Syntax check
# ── Vault ──
ansible-vault create secrets.yml # 14. Create an encrypted file
ansible-vault edit secrets.yml # 15. Edit an encrypted file
ansible-vault encrypt file.yml # 16. Encrypt a file
ansible-vault decrypt file.yml # 17. Decrypt a file
ansible-vault encrypt_string 'secret' --name key # 18. Encrypt a string
# ── Galaxy and utilities ──
ansible-galaxy role install geerlingguy.nginx # 19. Install a role
ansible-doc -s apt # 20. Module help
22. Troubleshooting
22.1 Terraform Troubleshooting
# ── 1. State lock problems ──
# Error: Error acquiring the state lock
# Cause: a previous terraform apply exited abnormally
# Fix:
terraform force-unlock LOCK_ID
# ── 2. Provider authentication failure ──
# Error: NoCredentialProviders
# Fix: check the AWS credentials
aws sts get-caller-identity
export AWS_PROFILE=myprofile
# ── 3. State and real infrastructure disagree ──
# Fix: refresh the state
terraform apply -refresh-only
# Remove the specific resource from state and import it again
terraform state rm aws_instance.web
terraform import aws_instance.web i-0abc123def
# ── 4. Provider version conflicts ──
# Fix: regenerate the lock file
rm .terraform.lock.hcl
terraform init -upgrade
# ── 5. Circular dependencies ──
# Error: Cycle detected
# Fix: check depends_on, split the resources apart
terraform graph | dot -Tpng > graph.png # Visualize the dependencies
# ── 6. Enable debug logging ──
export TF_LOG=DEBUG # TRACE, DEBUG, INFO, WARN, ERROR
export TF_LOG_PATH=terraform.log
terraform apply
# ── 7. Plan succeeds but apply fails ──
# Cause: insufficient API permissions, resource limits, network problems
# Fix: run sequentially with -parallelism=1 to pin down the exact error
terraform apply -parallelism=1
# ── 8. Performance problems with large state ──
# Fix: split the state (across several Terraform projects)
# Split into network/ compute/ database/ and so on, and
# connect them with the terraform_remote_state data source
22.2 Ansible Troubleshooting
# ── 1. SSH connection failure ──
# Error: UNREACHABLE!
# Debug:
ansible webservers -m ping -vvvv # Maximum verbosity
# Test SSH directly
ssh -i ~/.ssh/key.pem -o StrictHostKeyChecking=no ubuntu@10.0.1.10
# ── 2. sudo permission problems ──
# Error: Missing sudo password
# Fix:
ansible-playbook site.yml --ask-become-pass
# Or configure it in ansible.cfg:
# [privilege_escalation]
# become_ask_pass = True
# ── 3. Module not found ──
# Error: MODULE FAILURE
# Fix: install the collection
ansible-galaxy collection install amazon.aws
# ── 4. Jinja2 template errors ──
# Error: AnsibleUndefinedVariable
# Fix: check that the variable is defined
ansible-playbook site.yml -e "@vars/defaults.yml" --check
# Or use the default filter: "{{ my_var | default('fallback') }}"
# ── 5. Performance tuning ──
# ansible.cfg settings:
# [defaults]
# forks = 20 # Increase the parallelism
# gathering = smart # Fact caching
# [ssh_connection]
# pipelining = True # Enable SSH pipelining
# ssh_args = -o ControlMaster=auto -o ControlPersist=60s
# ── 6. Lost the Vault password ──
# Fix: unrecoverable. You have to re-encrypt with a new password
# While you still remember the password:
ansible-vault rekey secrets.yml
# ── 7. Inventory parsing errors ──
# Debug: validate the inventory
ansible-inventory -i inventory/hosts.yml --list --export
# ── 8. Broken idempotency (repeated changed) ──
# Add a creates/removes condition to the command/shell module
- name: Initialize database
command: /opt/app/init_db.sh
args:
creates: /opt/app/.db_initialized
23. A Summary of Best Practices
23.1 Terraform Best Practices
| Item | Recommendation |
|---|---|
| State management | Use a remote backend (S3 + DynamoDB); never commit it to Git |
| Directory structure | Separate by environment (envs/dev, envs/prod) or use workspaces |
| Code review | Attach the terraform plan output to the PR |
| Modularization | Split into reusable modules and tag their versions |
| Variable management | Mark sensitive variables sensitive = true; use env vars or Vault |
| Lock file | Always commit .terraform.lock.hcl to Git |
| CI/CD | Plan on the PR; apply automatically after the merge |
| Naming convention | Use the project-env-resource pattern consistently |
23.2 Ansible Best Practices
| Item | Recommendation |
|---|---|
| Inventory | Use a dynamic inventory in cloud environments |
| Sensitive data | Always encrypt it with Ansible Vault |
| Using roles | Structure tasks as roles; make good use of Galaxy roles |
| Idempotency | Prefer dedicated modules over command/shell; use creates/removes |
| Testing | Verify ahead of time with --check --diff; test roles with Molecule |
| Variable precedence | Understand variable precedence and define variables in the right place |
| Using tags | Tag every task so it can be run selectively |
| Logging | Measure performance with callback_whitelist = timer, profile_tasks |
24. References
24.1 Official Documentation
- Terraform: https://developer.hashicorp.com/terraform/docs
- Terraform Registry: https://registry.terraform.io
- OpenTofu: https://opentofu.org/docs
- Ansible: https://docs.ansible.com
- Ansible Galaxy: https://galaxy.ansible.com
24.2 Recommended Learning Resources
- Terraform Up and Running (Yevgeniy Brikman) — the Terraform bible
- Ansible for DevOps (Jeff Geerling) — a practical Ansible book
- HashiCorp Learn: https://developer.hashicorp.com/terraform/tutorials
- Jeff Geerling YouTube: plenty of hands-on Ansible videos
24.3 Related Certifications
- HashiCorp Certified: Terraform Associate (003): the foundational Terraform certification
- Red Hat Certified Specialist in Ansible Automation (EX374): the Ansible specialist certification
24.4 Community
- Terraform GitHub: https://github.com/hashicorp/terraform
- Ansible GitHub: https://github.com/ansible/ansible
- OpenTofu GitHub: https://github.com/opentofu/opentofu
- r/Terraform: the Reddit community
- r/ansible: the Reddit community
Terraform and Ansible are the two pillars of modern infrastructure automation. Provisioning infrastructure declaratively with Terraform and configuring software on top of it with Ansible has become the industry's de facto standard. As you apply the commands and patterns covered here to your own work, my hope is that you replace repetitive manual effort with code and establish a culture in your team of managing infrastructure safely and predictably.