Terraform Conditional Expressions and Logic — Summary Table
| # | Concept | Purpose | Example | Creates Resources? |
|---|---|---|---|---|
| 1 | Ternary operator | Choose between two values | var.env == "prod" ? "t3.large" : "t3.micro" | No |
| 2 | Equality operators | Check equality or inequality | var.env == "prod" | No |
| 3 | Comparison operators | Compare values | var.count > 2 | No |
| 4 | Logical AND (&&) | All conditions must be true | var.env == "prod" && var.monitoring | No |
| 5 | Logical OR (\|\|) | At least one condition must be true | var.env == "prod" \|\| var.env == "stage" | No |
| 6 | Logical NOT (!) | Reverse a Boolean value | !var.enable_public_ip | No |
| 7 | Parentheses | Group complex conditions | (var.env == "prod" \|\| var.env == "stage") && var.enabled | No |
| 8 | Conditional count | Create zero or one resource | count = var.enabled ? 1 : 0 | Yes |
| 9 | Conditional for_each | Create selected named resources | for_each = var.enabled ? var.instances : {} | Yes |
| 10 | Conditional locals | Select configuration by environment | instance_type = var.env == "prod" ? "t3.large" : "t3.micro" | No |
| 11 | Filter with for | Select collection elements that meet a condition | { for k, v in var.rules : k => v if v.enabled } | No, but can feed for_each |
| 12 | lookup() | Retrieve a map value with a default | lookup(var.sizes, var.env, "t3.micro") | No |
| 13 | try() | Fall back when an expression errors | try(var.settings.instance_type, "t3.micro") | No |
| 14 | can() | Check whether an expression evaluates without error | can(var.settings.port) | No |
| 15 | coalesce() | Return the first non-null, non-empty string | coalesce(var.name, "default-name") | No |
| 16 | Variable validation | Reject invalid input | validation { ... } | No |
| 17 | Preconditions | Enforce requirements before an operation | precondition { ... } | No |
| 18 | Postconditions | Verify a resource or data-source result | postcondition { ... } | No |
| 19 | Conditional dynamic block | Include nested configuration conditionally | for_each = var.enabled ? [1] : [] | No standalone resource |
| 20 | Conditional module | Deploy a module only when needed | count = var.create_network ? 1 : 0 | Yes, module instances |
Complete Master Guide with AWS and Azure Labs
Audience: Beginners to advanced DevOps / Cloud engineers
Terraform: Terraform 1.5+ recommended (some examples use features available in Terraform 1.2+)
Clouds: AWS EC2 and Azure Linux Virtual Machines
Format: Self-contained labs, explanations, run commands, expected outcomes, troubleshooting, and cleanup
Table of Contents
- Learning objectives
- Before you begin
- Conditional logic: the basic idea
- Lab 1 — Ternary conditional expression
- Lab 2 — Logical operators and compound conditions
- Lab 3 — Conditional resource creation with count
- Lab 4 — for_each with conditional filtering
- Lab 5 — Conditional configuration using locals
- Lab 6 — Dynamic blocks and optional nested rules
- Lab 7 — Input variable validation
- Lab 8 — Preconditions and postconditions
- Lab 9 — try(), can(), lookup(), and coalesce()
- Lab 10 — Conditional module instances
- Important patterns and gotchas
- Troubleshooting
- Best practices
- Quick reference
- Exercises and answers
- Glossary
1. Learning objectives
After completing this guide, you should be able to:
- Choose a value using Terraform’s conditional (
condition ? true_value : false_value) expression. - Combine conditions using
&&,||,!, and comparison operators. - Create zero, one, or many resources using
countandfor_each. - Filter collections using
forexpressions andif. - Generate nested configuration using
dynamicblocks. - Validate input values and enforce assumptions with preconditions and postconditions.
- Handle optional values and expression errors with
try,can,lookup, andcoalesce. - Test a configuration with
terraform plan,apply,output, anddestroy.
2. Before you begin
Install and authenticate
You need Terraform and credentials for the cloud you are testing.
AWS – Install the AWS CLI and Terraform. – Configure credentials, for example with aws configure, an AWS profile, or an IAM role. – The identity needs permission to create and delete EC2 instances and security groups. Depending on the lab, it may also need permissions for VPC-related resources. – Choose a region where your account can create the requested instance type.
Azure – Install Azure CLI and Terraform. – Sign in with az login. – Select the correct subscription: az account set --subscription "<SUBSCRIPTION_ID_OR_NAME>". – Your identity needs permission to create and delete resource groups and networking/compute resources. Subscription policy and regional quotas can affect VM creation.
Check tools:
terraform version
aws sts get-caller-identity
az account show --output table
Code language: JavaScript (javascript)
A safe lab workflow
For each lab: 1. Create a new, empty directory. 2. Save the complete example as main.tf. 3. Run terraform init. 4. Run terraform fmt. 5. Run terraform validate. 6. Review terraform plan. 7. Run terraform apply and confirm when ready. 8. Inspect the outputs and cloud console. 9. Run terraform destroy when finished.
Cost and security warning: Applying cloud labs can incur charges. Destroy resources after testing. Some examples create public IP addresses or permit SSH only from an address you specify. Do not use
0.0.0.0/0for SSH in production. Never commit cloud credentials or private keys to source control.
How the examples are organized
Each cloud example below is intended to be a complete main.tf for that scenario. AWS examples create EC2 infrastructure. Azure VM examples include a resource group, virtual network, subnet, network interface, public IP, network security group, and Linux VM. For simplicity, examples use common Ubuntu image identifiers; image availability and cloud policies can vary by region and subscription.
3. Conditional logic: the basic idea
Terraform configuration is declarative. You describe the desired infrastructure, and Terraform determines the actions needed to reach that state. Terraform does not use ordinary procedural if { ... } else { ... } statements around resource blocks. Instead, conditions are expressed through values, collection filtering, meta-arguments, and validation constructs.
The conditional expression
condition ? value_if_true : value_if_false
Example:
variable "environment" {
type = string
default = "dev"
}
locals {
instance_type = var.environment == "prod" ? "t3.medium" : "t3.micro"
}
Code language: JavaScript (javascript)
If environment is "prod", instance_type is "t3.medium". Otherwise, it is "t3.micro".
Logical and comparison operators
Operator Meaning Example
== Equal var.environment == "prod"
!= Not equal var.environment != "dev"
> / < Greater / less than var.instance_count > 1
>= / <= Greater/less than or var.port >= 1024 equal
&& AND: both sides must be var.enabled && var.environment == "prod" true
|| OR: either side may be var.environment == "dev" || var.environment == "test" true
! NOT: reverse a Boolean !var.disabled
Use parentheses for readability when combining multiple conditions.
4. Lab 1 — Ternary conditional expression
Goal
Choose a smaller compute size for non-production and a larger size for production. This lab demonstrates a condition selecting a value; it does not conditionally omit the resource.
4A. AWS — choose EC2 instance type
Create main.tf:
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "environment" {
type = string
description = "Set to dev or prod."
default = "dev"
validation {
condition = contains(["dev", "prod"], var.environment)
error_message = "environment must be dev or prod."
}
}
variable "ami_id" {
type = string
description = "A valid Ubuntu AMI ID in the selected AWS region."
}
variable "ssh_cidr" {
type = string
description = "Your public IPv4 address in CIDR notation, for example 203.0.113.10/32."
}
locals {
instance_type = var.environment == "prod" ? "t3.small" : "t3.micro"
}
resource "aws_security_group" "web" {
name_prefix = "conditional-lab-"
description = "SSH for a temporary Terraform conditional-expression lab"
ingress {
description = "SSH from the student's address only"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "conditional-lab-sg"
}
}
resource "aws_instance" "example" {
ami = var.ami_id
instance_type = local.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = "conditional-${var.environment}"
Environment = var.environment
}
}
output "selected_instance_type" {
value = local.instance_type
}
output "instance_id" {
value = aws_instance.example.id
}
output "public_ip" {
value = aws_instance.example.public_ip
}
Code language: JavaScript (javascript)
Run it (replace the placeholders with values valid for your account and region):
terraform init
terraform fmt
terraform validate
terraform plan \
-var="ami_id=ami-REPLACE_WITH_VALID_UBUNTU_AMI" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply \
-var="ami_id=ami-REPLACE_WITH_VALID_UBUNTU_AMI" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
terraform destroy \
-var="ami_id=ami-REPLACE_WITH_VALID_UBUNTU_AMI" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
Code language: JavaScript (javascript)
Observe: With the default environment = "dev", output selected_instance_type is t3.micro. To test production, add -var="environment=prod" to both plan and apply.
AMI tip: AMI IDs are regional. Find a current Ubuntu AMI in the selected region using the AWS console or Canonical’s published images. Do not copy an AMI ID from a different region.
4B. Azure — choose Linux VM size
Create main.tf:
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "prod"], var.environment)
error_message = "environment must be dev or prod."
}
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
description = "Contents of your SSH public key, not the private key."
}
variable "ssh_cidr" {
type = string
description = "Your public IPv4 address in CIDR notation, for example 203.0.113.10/32."
}
locals {
vm_size = var.environment == "prod" ? "Standard_B2s" : "Standard_B1s"
prefix = "tfcond-${var.environment}"
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.20.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.20.1.0/24"]
}
resource "azurerm_public_ip" "lab" {
name = "${local.prefix}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromMyIP"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_network_interface" "lab" {
name = "${local.prefix}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.lab.id
}
}
resource "azurerm_network_interface_security_group_association" "lab" {
network_interface_id = azurerm_network_interface.lab.id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "lab" {
name = "${local.prefix}-vm"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = local.vm_size
admin_username = var.admin_username
network_interface_ids = [
azurerm_network_interface.lab.id
]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Environment = var.environment
}
}
output "selected_vm_size" {
value = local.vm_size
}
output "public_ip" {
value = azurerm_public_ip.lab.ip_address
}
output "ssh_command" {
value = "ssh ${var.admin_username}@${azurerm_public_ip.lab.ip_address}"
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform fmt
terraform validate
terraform plan \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
terraform destroy \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
Code language: JavaScript (javascript)
If you use a different public key file, substitute its path. If ~/.ssh/id_ed25519.pub does not exist, create a key pair with ssh-keygen -t ed25519. Check that the selected VM size is available and permitted in the subscription and region.
Observe: The default selects Standard_B1s; -var="environment=prod" selects Standard_B2s. These are example sizes, not a guarantee of regional availability or quota.
5. Lab 2 — Logical operators and compound conditions
Goal
Use more than one input to decide a value. This is useful when production settings should be selected only when the environment is production and a feature is enabled.
5A. AWS — conditionally choose instance type
Save as main.tf:
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "environment" {
type = string
default = "prod"
}
variable "high_performance_enabled" {
type = bool
default = true
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
locals {
use_large_instance = var.environment == "prod" && var.high_performance_enabled
instance_type = local.use_large_instance ? "t3.small" : "t3.micro"
environment_class = var.environment == "dev" || var.environment == "test" ? "nonproduction" : "production-like"
feature_disabled = !var.high_performance_enabled
}
resource "aws_security_group" "lab" {
name_prefix = "logic-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "lab" {
ami = var.ami_id
instance_type = local.instance_type
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "logic-lab"
EnvironmentClass = local.environment_class
HighPerformanceOff = tostring(local.feature_disabled)
}
}
output "use_large_instance" {
value = local.use_large_instance
}
output "instance_type" {
value = local.instance_type
}
output "environment_class" {
value = local.environment_class
}
Code language: JavaScript (javascript)
Run with the same terraform init, validate, plan, apply, output, and destroy workflow used in Lab 1. Supply ami_id and ssh_cidr as variables. Try:
terraform plan \
-var="ami_id=ami-REPLACE_WITH_VALID_AMI" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32" \
-var="environment=prod" \
-var="high_performance_enabled=true"
Code language: JavaScript (javascript)
Then change high_performance_enabled=false and inspect the plan. The selected instance type changes from t3.small to t3.micro.
5B. Azure — logical operators choose VM size
Save as main.tf:
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "environment" {
type = string
default = "prod"
}
variable "high_performance_enabled" {
type = bool
default = true
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
locals {
use_large_vm = var.environment == "prod" && var.high_performance_enabled
vm_size = local.use_large_vm ? "Standard_B2s" : "Standard_B1s"
environment_class = var.environment == "dev" || var.environment == "test" ? "nonproduction" : "production-like"
prefix = "tflogic"
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.30.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.30.1.0/24"]
}
resource "azurerm_public_ip" "lab" {
name = "${local.prefix}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromMyIP"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_network_interface" "lab" {
name = "${local.prefix}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.lab.id
}
}
resource "azurerm_network_interface_security_group_association" "lab" {
network_interface_id = azurerm_network_interface.lab.id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "lab" {
name = "${local.prefix}-vm"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = local.vm_size
admin_username = var.admin_username
network_interface_ids = [
azurerm_network_interface.lab.id
]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
EnvironmentClass = local.environment_class
}
}
output "use_large_vm" {
value = local.use_large_vm
}
output "vm_size" {
value = local.vm_size
}
output "environment_class" {
value = local.environment_class
}
output "public_ip" {
value = azurerm_public_ip.lab.ip_address
}
Code language: JavaScript (javascript)
Run the standard workflow and provide:
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
Code language: JavaScript (javascript)
Test these combinations: – environment=prod, high_performance_enabled=true → larger VM. – environment=prod, high_performance_enabled=false → smaller VM. – environment=dev, high_performance_enabled=true → smaller VM.
6. Lab 3 — Conditional resource creation with count
Concept
Use count = condition ? 1 : 0 when a resource should exist only when a condition is true. When the condition is false, Terraform plans zero instances of that resource.
Important: Resources using count are addressed with numeric indexes, such as aws_instance.optional[0]. Changing count from one to zero destroys that instance when you apply the change.
6A. AWS — optional EC2 instance
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "create_instance" {
type = bool
default = true
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
resource "aws_security_group" "lab" {
name_prefix = "count-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "optional" {
count = var.create_instance ? 1 : 0
ami = var.ami_id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "optional-ec2"
}
}
output "instance_ids" {
value = aws_instance.optional[*].id
}
output "instance_count" {
value = length(aws_instance.optional)
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform validate
terraform plan -var="ami_id=ami-REPLACE_WITH_VALID_AMI" -var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply -var="ami_id=ami-REPLACE_WITH_VALID_AMI" -var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
Code language: JavaScript (javascript)
To turn off the instance:
terraform plan -var="create_instance=false" -var="ami_id=ami-REPLACE_WITH_VALID_AMI" -var="ssh_cidr=YOUR_PUBLIC_IP/32"
Code language: JavaScript (javascript)
If the state currently contains the instance, applying this plan destroys it. Finish with terraform destroy, using the same required variable values.
6B. Azure — optional Linux VM
This example conditionally creates the VM. The network foundation remains present while create_vm = false, which makes the effect easy to observe.
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "create_vm" {
type = bool
default = true
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
locals {
prefix = "tfcount"
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.40.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.40.1.0/24"]
}
resource "azurerm_public_ip" "lab" {
count = var.create_vm ? 1 : 0
name = "${local.prefix}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromMyIP"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_network_interface" "lab" {
count = var.create_vm ? 1 : 0
name = "${local.prefix}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.lab[0].id
}
}
resource "azurerm_network_interface_security_group_association" "lab" {
count = var.create_vm ? 1 : 0
network_interface_id = azurerm_network_interface.lab[0].id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "lab" {
count = var.create_vm ? 1 : 0
name = "${local.prefix}-vm"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = "Standard_B1s"
admin_username = var.admin_username
network_interface_ids = [
azurerm_network_interface.lab[0].id
]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
}
output "vm_count" {
value = length(azurerm_linux_virtual_machine.lab)
}
output "public_ip" {
value = var.create_vm ? azurerm_public_ip.lab[0].ip_address : null
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform fmt
terraform validate
terraform plan \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
Code language: JavaScript (javascript)
To test no VM:
terraform plan \
-var="create_vm=false" \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
Code language: JavaScript (javascript)
The VM, NIC, and public IP are removed if you apply that change. The resource group, VNet, subnet, and NSG remain until destroyed. Destroy the lab when finished.
7. Lab 4 — for_each with conditional filtering
Concept
Use for_each when each instance has a stable key. A filtered map can include only items that meet a condition. Unlike count, for_each instances are addressed by keys rather than numeric positions.
7A. AWS — create EC2 instances only for enabled applications
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = map(object({
enabled = bool
type = string
}))
default = {
frontend = { enabled = true, type = "t3.micro" }
backend = { enabled = true, type = "t3.micro" }
sandbox = { enabled = false, type = "t3.micro" }
}
}
locals {
enabled_applications = {
for name, app in var.applications : name => app
if app.enabled
}
}
resource "aws_security_group" "lab" {
name_prefix = "foreach-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "app" {
for_each = local.enabled_applications
ami = var.ami_id
instance_type = each.value.type
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "app-${each.key}"
Application = each.key
}
}
output "created_instance_ids" {
value = { for name, instance in aws_instance.app : name => instance.id }
}
output "created_application_names" {
value = keys(aws_instance.app)
}
Code language: JavaScript (javascript)
Run using the standard workflow and supply ami_id and ssh_cidr. The defaults create frontend and backend, but not sandbox.
To enable sandbox, pass a complete replacement map through a .tfvars file or change the default. For example, create terraform.tfvars:
applications = {
frontend = { enabled = true, type = "t3.micro" }
backend = { enabled = true, type = "t3.micro" }
sandbox = { enabled = true, type = "t3.micro" }
}
Code language: JavaScript (javascript)
Then run terraform plan and terraform apply.
7B. Azure — create Linux VMs only for enabled applications
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = map(object({
enabled = bool
size = string
}))
default = {
frontend = { enabled = true, size = "Standard_B1s" }
backend = { enabled = true, size = "Standard_B1s" }
sandbox = { enabled = false, size = "Standard_B1s" }
}
}
locals {
prefix = "tfforeach"
enabled_applications = {
for name, app in var.applications : name => app
if app.enabled
}
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.50.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.50.1.0/24"]
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromMyIP"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_public_ip" "app" {
for_each = local.enabled_applications
name = "${local.prefix}-${each.key}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "app" {
for_each = local.enabled_applications
name = "${local.prefix}-${each.key}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.app[each.key].id
}
}
resource "azurerm_network_interface_security_group_association" "app" {
for_each = local.enabled_applications
network_interface_id = azurerm_network_interface.app[each.key].id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "app" {
for_each = local.enabled_applications
name = "${local.prefix}-${each.key}-vm"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = each.value.size
admin_username = var.admin_username
network_interface_ids = [
azurerm_network_interface.app[each.key].id
]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Application = each.key
}
}
output "created_vm_names" {
value = keys(azurerm_linux_virtual_machine.app)
}
output "public_ips" {
value = { for name, ip in azurerm_public_ip.app : name => ip.ip_address }
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform fmt
terraform validate
terraform plan \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
terraform destroy \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
Code language: JavaScript (javascript)
The default creates two VMs. Enabling or disabling an application changes only that keyed instance and its related networking resources.
8. Lab 5 — Conditional configuration using locals
Concept
A local value can centralize decisions that are reused across resources. This makes configuration easier to read and reduces repeated expressions.
8A. AWS — environment-based sizing and tags
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "Use dev, test, or prod."
}
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
locals {
settings = {
dev = { type = "t3.micro", monitoring = false }
test = { type = "t3.micro", monitoring = false }
prod = { type = "t3.small", monitoring = true }
}
selected = local.settings[var.environment]
name = "app-${var.environment}"
}
resource "aws_security_group" "lab" {
name_prefix = "locals-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "lab" {
ami = var.ami_id
instance_type = local.selected.type
monitoring = local.selected.monitoring
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = local.name
Environment = var.environment
}
}
output "selected_settings" {
value = local.selected
}
Code language: JavaScript (javascript)
Run with environment=dev, then environment=prod. Observe the instance type and monitoring setting in terraform plan.
8B. Azure — environment-based VM size and tags
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "Use dev, test, or prod."
}
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
locals {
settings = {
dev = { size = "Standard_B1s", monitoring = "basic" }
test = { size = "Standard_B1s", monitoring = "basic" }
prod = { size = "Standard_B2s", monitoring = "enhanced" }
}
selected = local.settings[var.environment]
prefix = "tflocals-${var.environment}"
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
tags = {
Environment = var.environment
Monitoring = local.selected.monitoring
}
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.60.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.60.1.0/24"]
}
resource "azurerm_public_ip" "lab" {
name = "${local.prefix}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromMyIP"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_network_interface" "lab" {
name = "${local.prefix}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.lab.id
}
}
resource "azurerm_network_interface_security_group_association" "lab" {
network_interface_id = azurerm_network_interface.lab.id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "lab" {
name = "${local.prefix}-vm"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = local.selected.size
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.lab.id]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Environment = var.environment
Monitoring = local.selected.monitoring
}
}
output "selected_settings" {
value = local.selected
}
output "public_ip" {
value = azurerm_public_ip.lab.ip_address
}
Code language: JavaScript (javascript)
Run using the Azure workflow from Lab 1, with the SSH public key and CIDR variables. Compare the plan for environment=dev and environment=prod.
9. Lab 6 — Dynamic blocks and optional nested rules
Concept
A dynamic block generates nested blocks inside a resource. It is useful for a variable number of ingress rules or similar nested provider configuration. It does not create multiple top-level resources.
9A. AWS — dynamic ingress rules
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "ssh_cidr" {
type = string
}
variable "enable_http" {
type = bool
default = true
}
locals {
base_rules = [
{ name = "ssh", port = 22, cidr = var.ssh_cidr },
]
http_rules = var.enable_http ? [
{ name = "http", port = 80, cidr = "10.0.0.0/8" }
] : []
ingress_rules = concat(local.base_rules, local.http_rules)
}
resource "aws_security_group" "lab" {
name_prefix = "dynamic-lab-"
description = "Dynamic ingress rule demonstration"
dynamic "ingress" {
for_each = local.ingress_rules
iterator = rule
content {
description = rule.value.name
from_port = rule.value.port
to_port = rule.value.port
protocol = "tcp"
cidr_blocks = [rule.value.cidr]
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "dynamic-lab-sg"
}
}
output "configured_ingress_rules" {
value = local.ingress_rules
}
Code language: JavaScript (javascript)
Run terraform init, terraform validate, and terraform plan. Set -var="enable_http=false" to see the HTTP rule disappear from the plan. This lab creates only a security group, so it does not need an EC2 instance.
9B. Azure — optional SSH and HTTP security rules
Azure NSG rules are separate resources, so use for_each rather than a dynamic nested block.
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "ssh_cidr" {
type = string
}
variable "enable_http" {
type = bool
default = true
}
locals {
rules = merge(
{
ssh = {
priority = 100
port = "22"
source = var.ssh_cidr
}
},
var.enable_http ? {
http = {
priority = 110
port = "80"
source = "10.0.0.0/8"
}
} : {}
)
}
resource "azurerm_resource_group" "lab" {
name = "tfdynamic-rg"
location = var.location
}
resource "azurerm_network_security_group" "lab" {
name = "tfdynamic-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_network_security_rule" "rule" {
for_each = local.rules
name = "Allow-${each.key}"
priority = each.value.priority
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = each.value.port
source_address_prefix = each.value.source
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.lab.name
network_security_group_name = azurerm_network_security_group.lab.name
}
output "created_rule_names" {
value = keys(azurerm_network_security_rule.rule)
}
Code language: JavaScript (javascript)
Run terraform plan, then repeat with -var="enable_http=false". This example intentionally shows the Azure-native pattern: separate NSG rules are managed with for_each.
10. Lab 7 — Input variable validation
Concept
Variable validation checks user-provided values and stops planning with a helpful message when the value is invalid. Validation is suitable for allowed environment names, port ranges, naming rules, and other input constraints.
7A. AWS — validate environment and port
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "environment must be dev, test, or prod."
}
}
variable "application_port" {
type = number
default = 8080
validation {
condition = var.application_port >= 1 && var.application_port <= 65535
error_message = "application_port must be between 1 and 65535."
}
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
resource "aws_security_group" "lab" {
name_prefix = "validation-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
ingress {
from_port = var.application_port
to_port = var.application_port
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "lab" {
ami = var.ami_id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "validation-${var.environment}"
Environment = var.environment
}
}
output "application_port" {
value = var.application_port
}
Code language: JavaScript (javascript)
Test valid input, then invalid input:
terraform init
terraform validate
terraform plan -var="ami_id=ami-REPLACE_WITH_VALID_AMI" -var="ssh_cidr=YOUR_PUBLIC_IP/32" -var="environment=prod" -var="application_port=8080"
terraform plan -var="ami_id=ami-REPLACE_WITH_VALID_AMI" -var="ssh_cidr=YOUR_PUBLIC_IP/32" -var="application_port=70000"
Code language: JavaScript (javascript)
The second plan should fail variable validation before infrastructure is created.
7B. Azure — validate environment and SSH port concept
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "environment must be dev, test, or prod."
}
}
variable "ssh_port" {
type = number
default = 22
validation {
condition = var.ssh_port >= 1 && var.ssh_port <= 65535
error_message = "ssh_port must be between 1 and 65535."
}
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
resource "azurerm_resource_group" "lab" {
name = "tfvalidation-${var.environment}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "tfvalidation-vnet"
address_space = ["10.70.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.70.1.0/24"]
}
resource "azurerm_public_ip" "lab" {
name = "tfvalidation-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_security_group" "lab" {
name = "tfvalidation-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = tostring(var.ssh_port)
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_network_interface" "lab" {
name = "tfvalidation-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.lab.id
}
}
resource "azurerm_network_interface_security_group_association" "lab" {
network_interface_id = azurerm_network_interface.lab.id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "lab" {
name = "tfvalidation-vm"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = "Standard_B1s"
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.lab.id]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Environment = var.environment
}
}
output "environment" {
value = var.environment
}
output "public_ip" {
value = azurerm_public_ip.lab.ip_address
}
Code language: JavaScript (javascript)
Run with ssh_public_key and ssh_cidr. Try -var="ssh_port=70000"; validation should reject it. The example permits a custom SSH port to demonstrate numeric validation; the VM’s SSH daemon itself remains configured on port 22 unless separately changed, so keep ssh_port=22 for actual SSH access.
11. Lab 8 — Preconditions and postconditions
Concept
- Precondition: verifies an assumption before Terraform proceeds with the relevant operation.
- Postcondition: verifies a property of a resource after Terraform reads or creates it.
- Check block: performs an assertion and reports a warning if it fails; it is not a substitute for blocking validation.
These constructs are useful for expressing requirements close to the resource or output they protect.
8A. AWS — enforce a production tag with a precondition
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "environment" {
type = string
default = "prod"
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
resource "aws_security_group" "lab" {
name_prefix = "condition-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "lab" {
ami = var.ami_id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "precondition-lab"
Environment = var.environment
}
lifecycle {
precondition {
condition = contains(["test", "prod"], var.environment)
error_message = "This lab permits only test or prod environments."
}
postcondition {
condition = self.instance_type == "t3.micro"
error_message = "The instance type must remain t3.micro for this lab."
}
}
}
output "instance_id" {
value = aws_instance.lab.id
}
Code language: PHP (php)
Run with environment=prod and then try environment=dev. The latter should fail the precondition. This example’s postcondition checks the configured instance type; it demonstrates the syntax rather than a cloud health check.
8B. Azure — precondition on VM configuration
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "environment" {
type = string
default = "prod"
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
locals {
vm_size = "Standard_B1s"
}
resource "azurerm_resource_group" "lab" {
name = "tfcondition-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "tfcondition-vnet"
address_space = ["10.80.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.80.1.0/24"]
}
resource "azurerm_public_ip" "lab" {
name = "tfcondition-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_security_group" "lab" {
name = "tfcondition-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_network_interface" "lab" {
name = "tfcondition-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.lab.id
}
}
resource "azurerm_network_interface_security_group_association" "lab" {
network_interface_id = azurerm_network_interface.lab.id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "lab" {
name = "tfcondition-vm"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = local.vm_size
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.lab.id]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
lifecycle {
precondition {
condition = contains(["test", "prod"], var.environment)
error_message = "This lab permits only test or prod environments."
}
postcondition {
condition = self.size == local.vm_size
error_message = "The VM size does not match the selected configuration."
}
}
tags = {
Environment = var.environment
}
}
output "vm_id" {
value = azurerm_linux_virtual_machine.lab.id
}
Code language: PHP (php)
Run with environment=prod, then try environment=dev and inspect the validation failure. Use the same Azure key/CIDR inputs and cleanup procedure as earlier Azure labs.
12. Lab 9 — try(), can(), lookup(), and coalesce()
Concept
These functions are often used alongside conditions, but they are not all conditional expressions.
try(a, b)returns the first expression that does not produce an error.can(expression)returnstrueif the expression can be evaluated without an error.lookup(map, key, default)returns a map value or a fallback.coalesce(a, b, ...)returns the first non-null, non-empty-string value.contains(list, value)tests membership.
Do not use these functions to hide errors that should be fixed directly.
9A. AWS — safe defaults and conditional configuration
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "environment" {
type = string
default = "dev"
}
variable "custom_instance_type" {
type = string
default = ""
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
locals {
instance_types = {
dev = "t3.micro"
test = "t3.micro"
prod = "t3.small"
}
default_type = lookup(local.instance_types, var.environment, "t3.micro")
chosen_type = coalesce(var.custom_instance_type, local.default_type)
environment_is_known = contains(keys(local.instance_types), var.environment)
parsed_number_is_valid = can(tonumber("123"))
safe_environment = try(trimspace(var.environment), "dev")
}
resource "aws_security_group" "lab" {
name_prefix = "functions-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "lab" {
ami = var.ami_id
instance_type = local.chosen_type
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "functions-lab"
Environment = local.safe_environment
}
}
output "selected_instance_type" {
value = local.chosen_type
}
output "environment_is_known" {
value = local.environment_is_known
}
output "parsed_number_is_valid" {
value = local.parsed_number_is_valid
}
Code language: JavaScript (javascript)
Run with the usual AWS commands and required inputs. Try -var="environment=prod" and then -var="custom_instance_type=t3.small". The explicit custom value takes precedence. The example uses can() and try() to illustrate safe expression evaluation; they do not verify that an instance type is available in your account or region.
9B. Azure — lookup and coalesce for VM size and name
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "environment" {
type = string
default = "dev"
}
variable "custom_vm_size" {
type = string
default = ""
}
variable "custom_name" {
type = string
default = ""
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
locals {
vm_sizes = {
dev = "Standard_B1s"
test = "Standard_B1s"
prod = "Standard_B2s"
}
default_size = lookup(local.vm_sizes, var.environment, "Standard_B1s")
vm_size = coalesce(var.custom_vm_size, local.default_size)
vm_name = coalesce(var.custom_name, "tf-functions-${var.environment}-vm")
environment_is_known = contains(keys(local.vm_sizes), var.environment)
number_parse_works = can(tonumber("123"))
safe_environment = try(trimspace(var.environment), "dev")
}
resource "azurerm_resource_group" "lab" {
name = "tffunctions-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "tffunctions-vnet"
address_space = ["10.90.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.90.1.0/24"]
}
resource "azurerm_public_ip" "lab" {
name = "tffunctions-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_security_group" "lab" {
name = "tffunctions-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_network_interface" "lab" {
name = "tffunctions-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.lab.id
}
}
resource "azurerm_network_interface_security_group_association" "lab" {
network_interface_id = azurerm_network_interface.lab.id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "lab" {
name = local.vm_name
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = local.vm_size
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.lab.id]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Environment = local.safe_environment
}
}
output "selected_vm_size" {
value = local.vm_size
}
output "vm_name" {
value = local.vm_name
}
output "environment_is_known" {
value = local.environment_is_known
}
output "number_parse_works" {
value = local.number_parse_works
}
output "public_ip" {
value = azurerm_public_ip.lab.ip_address
}
Code language: JavaScript (javascript)
Run with the standard Azure workflow. Try -var="environment=prod" and -var="custom_vm_size=Standard_B1s". coalesce chooses the non-empty custom value when supplied. Confirm the chosen size is available before applying.
13. Lab 10 — Conditional module instances
Concept
Modules can use count or for_each, just like resources. When using count, outputs from the module become a list of module instances. A module using for_each is addressed by its keys.
This lab is deliberately a small local module example so it can be tested without creating cloud resources. It demonstrates Terraform language behavior; cloud-specific modules can use the same pattern.
Create this directory layout:
conditional-module-lab/
├── main.tf
└── modules/
└── label/
└── main.tf
Code language: JavaScript (javascript)
Root main.tf:
terraform {
required_version = ">= 1.3.0"
}
variable "create_label" {
type = bool
default = true
}
module "label" {
source = "./modules/label"
count = var.create_label ? 1 : 0
label = "demo"
}
output "label_value" {
value = var.create_label ? module.label[0].value : null
}
Code language: JavaScript (javascript)
modules/label/main.tf:
variable "label" {
type = string
}
output "value" {
value = upper(var.label)
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform validate
terraform plan
terraform apply
terraform output
terraform plan -var="create_label=false"
Code language: JavaScript (javascript)
This demonstrates conditional module instantiation. No cloud infrastructure is created.
14. Important patterns and gotchas
count versus for_each
Feature count for_each
Input Non-negative integer Map or set of strings
Address Numeric index, Key, e.g. [0] e.g. ["frontend"]
Best for Fixed number or simple Named resources with on/off stable identity
Main risk Removing an element Renaming a key changes from a list can shift the resource address indexes
Conditional expressions must have compatible result types
Good:
var.enabled ? "enabled" : "disabled"
Code language: JavaScript (javascript)
Potentially problematic if the branches have incompatible types:
var.enabled ? 1 : "no"
Code language: JavaScript (javascript)
Keep both branches the same type where possible.
Do not use count to make a resource depend on an unknown result
Terraform must know count and for_each instance keys during planning. If the condition depends on an attribute that is unknown until apply, Terraform may not be able to determine the instance count. Base instance selection on known input variables or known configuration whenever possible.
Resource references change when count or for_each is used
Without count:
aws_instance.web.id
Code language: CSS (css)
With count:
aws_instance.web[0].id
aws_instance.web[*].id
Code language: CSS (css)
With for_each:
aws_instance.web["frontend"].id
Code language: CSS (css)
null is not the same as false
falseis a Boolean value.nullmeans absence of a value and can omit an optional argument.- An empty string (
"") is still a string. - An empty list (
[]) is a collection with zero elements.
dynamic is for nested blocks
Use for_each on a resource or module to repeat top-level instances. Use dynamic when you need repeated nested blocks within one resource.
Azure NSG rules and AWS security group rules differ
The AWS provider supports inline ingress/egress blocks, including dynamic blocks in supported resource schemas. Azure NSG rules are commonly represented as separate azurerm_network_security_rule resources, making for_each the natural pattern.
try() and can() are not generic exception handling
Use them for expressions that might be absent or invalid, especially when normalizing external or loosely structured data. Do not wrap every expression in try() to conceal configuration mistakes.
Precondition, postcondition, and check are different
- Preconditions and postconditions can block an operation when their assertions fail.
checkblocks are designed for ongoing checks and generally report warnings rather than blocking the operation.- Variable validation is usually the clearest choice for invalid user inputs.
15. Troubleshooting
Error: Invalid for_each argument
Common cause: The keys or set members cannot be determined until apply, or the value is not a map/set of strings.
Fix: Use stable keys known during planning. For maps of objects, keep the keys independent of computed cloud resource IDs.
Error: Invalid count argument
Common cause: Terraform cannot determine the count during planning.
Fix: Use a Boolean input variable or another value known before apply.
Error: Invalid index
Common cause: Referencing resource.example[0] when count = 0.
Fix: Guard the reference with a condition, or use a splat expression such as resource.example[*].id.
Azure VM size or quota errors
Common cause: The selected VM size is unavailable in the region, restricted by policy, or exceeds quota.
Fix: Check Azure regional availability, subscription quota, and policy. Choose a size permitted by the subscription; changing a Terraform condition cannot grant quota.
AWS AMI not found
Common cause: AMI IDs are regional, stale, or incorrect.
Fix: Look up a valid image in the configured AWS region and pass its ID to ami_id.
SSH connection fails
Check: – ssh_cidr matches your current public IPv4 address in CIDR notation (often /32). – You supplied the public key, not the private key, to Azure. – The matching private key is available locally. – The cloud resource has finished provisioning. – The selected region, route, security rules, and OS image are correct. – For Azure, if you changed the NSG destination port, the Linux SSH daemon must also be configured for that port.
Provider version or registration issues
Run:
terraform init -upgrade
terraform providers
terraform validate
Review provider constraints and your organization’s Azure provider-registration policy. Do not run init -upgrade blindly in production without reviewing the provider version changes.
16. Best practices
- Prefer
for_eachfor named resources where stable keys matter. - Use
countfor simple on/off resources or truly interchangeable instances. - Put complex decisions in
localsand give them descriptive names. - Validate inputs early with variable
validationblocks. - Use preconditions and postconditions for assumptions tied to resources.
- Keep environment settings in a map/object instead of deeply nested ternaries.
- Keep security rules restrictive. Never expose SSH publicly just to simplify a lab.
- Pin provider major versions and review upgrades.
- Inspect
terraform planbefore every apply. - Use separate directories or isolated state for each hands-on lab.
- Destroy temporary lab resources to avoid ongoing costs.
- Avoid changing
for_eachkeys orcountlist order in existing production state without understanding the resulting resource address changes. - Keep secrets out of
.tffiles and source control. - Test conditions by changing one input at a time and comparing plans.
17. Quick reference
Need Recommended technique
Choose one value or another condition ? a : b
Combine Boolean requirements &&, ||, !
Create a resource only when enabled count = var.enabled ? 1 : 0
Create named resources from a for_each map/set
Filter a list or map for ... if ...
Transform a collection for expression
Repeat nested provider blocks dynamic
Reject invalid input Variable validation
Enforce an assumption before/after precondition / postcondition a resource operation
Report an ongoing assertion warning check block
Safely attempt an expression try()
Test if an expression evaluates can() without error
Get a map value with a fallback lookup()
Choose first non-null/non-empty coalesce() string
Check whether a collection contains contains() a value
Provide optional argument absence null
18. Exercises and answers
Exercise 1
Choose t3.small only if environment is prod; otherwise choose t3.micro.
Answer:
locals {
instance_type = var.environment == "prod" ? "t3.small" : "t3.micro"
}
Code language: JavaScript (javascript)
Exercise 2
Create an EC2 instance only if create_instance is true.
Answer:
resource "aws_instance" "optional" {
count = var.create_instance ? 1 : 0
# Include the required AMI, instance type, and other settings.
}
Code language: PHP (php)
Exercise 3
Filter a map of applications to keep only enabled applications.
Answer:
locals {
enabled_applications = {
for name, app in var.applications : name => app
if app.enabled
}
}
Code language: JavaScript (javascript)
Exercise 4
Allow only dev, test, or prod.
Answer:
variable "environment" {
type = string
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "Use dev, test, or prod."
}
}
Code language: JavaScript (javascript)
Exercise 5
What is the difference between dynamic and for_each?
Answer: for_each repeats resource or module instances. A dynamic block generates repeated nested blocks inside a resource.
Exercise 6
Why might Terraform reject a count expression?
Answer: Terraform must know how many instances to plan. If the count depends on a value that is unknown until apply, Terraform may not be able to calculate it.
19. Glossary
- Conditional expression: Selects a value based on a Boolean condition.
- Boolean: A value that is either
trueorfalse. - Meta-argument: A special argument such as
count,for_each, ordepends_onthat changes Terraform’s resource/module behavior. - Collection: A list, tuple, set, map, or object.
- Iteration: Processing or creating configuration for multiple elements.
- Filtering: Selecting only collection elements that meet a condition.
- Dynamic block: A construct that generates nested blocks from a collection.
- Precondition: An assertion that must hold before a relevant operation can proceed.
- Postcondition: An assertion evaluated after a resource operation/read.
- Plan: Terraform’s preview of proposed infrastructure changes.
- State: Terraform’s record of managed infrastructure objects.
Final checklist
Before finishing a lab, verify:
- [ ]
terraform fmtcompleted. - [ ]
terraform validatepassed. - [ ] The plan matches the condition you intended to test.
- [ ] Outputs show the expected value or number of instances.
- [ ] You inspected the cloud console or CLI where appropriate.
- [ ] You ran
terraform destroyfor temporary infrastructure. - [ ] You checked that no lab resources remain.
Remember: Terraform conditions decide values and desired configuration. Terraform then compares that configuration with state and real infrastructure to calculate a plan. Always review the plan before applying it.
I’m Rajesh Kumar, a DevOps, SRE, DevSecOps, Cloud, and Platform Engineering expert passionate about sharing practical knowledge, real-world experiences, and industry best practices. I have worked at Cotocus and regularly write about technology, travel, investing, health, product reviews, and digital marketing through my various platforms.
I publish technical articles at DevOps School, travel stories at Holiday Landmark, stock market insights at Stocks Mantra, health and fitness guidance at My Medic Plus, product reviews at TrueReviewNow, and SEO and digital marketing strategies at Wizbrand.
Find Trusted Cardiac Hospitals
Compare heart hospitals by city and services — all in one place.
Explore Hospitals