Advanced course by Ned Bellavance (Ned in the Cloud, HashiCorp Ambassador) Exercises: github.com/ned1313/Deep-Dive-Terraform
Table of Contents
- Overview and Prerequisites
- Working with Existing Resources — Import
- Team Collaboration — State and Remote Backend
- CI/CD Pipeline with Terraform
- Supporting Multiple Environments
- Connecting Multiple Configurations
- Configuration Management with Ansible
- Managing Sensitive Data
- Reference — Terraform Functions
- Reference — Meta-arguments
- Reference — Terraform Environment Variables
- Advanced HCL Patterns
1. Overview and Prerequisites
Course Positioning
This course targets those who already have Terraform basics and want to tackle enterprise use cases: importing existing resources, remote state, CI/CD pipelines, multi-environment management, sharing information between configurations, configuration management, and securing sensitive data.
Training Global Architecture
flowchart TD
A[Terraform Deep Dive] --> B[Importing existing resources]
A --> C[Remote State & Collaboration]
A --> D[CI/CD Pipeline]
A --> E[Multi-environments]
A --> F[Connecting configurations]
A --> G[Configuration Management]
A --> H[Sensitive data]
B --> B1[terraform import command]
B --> B2[import block HCL]
C --> C1[Terraform Cloud]
C --> C2[State backends]
C --> C3[terraform state commands]
D --> D1[GitHub Actions]
D --> D2[Terraform Cloud VCS workflow]
D --> D3[Pull Requests & Speculative Plans]
E --> E1[OSS Workspaces]
E --> E2[Separate Directories]
E --> E3[Git Branches]
E --> E4[Separate Repositories]
F --> F1[terraform_remote_state]
F --> F2[tfe_outputs data source]
G --> G1[Ansible Playbooks]
G --> G2[user_data]
G --> G3[terraform_data resource]
H --> H1[AWS Secrets Manager]
H --> H2[SSM Parameter Store]
H --> H3[sensitive argument]
Technical Prerequisites
| Skill | Required Level |
|---|---|
| Terraform CLI (plan, apply, destroy) | Mastery |
| HCL — variables, outputs, resources, data sources | Mastery |
| Provider plugins | Knowledge |
| AWS (VPC, EC2, IAM) | Basic knowledge |
| Git / GitHub | Basic knowledge |
Technologies Used in the Course
- AWS — primary cloud provider (VPC, EC2, Security Groups, Secrets Manager, SSM)
- GitHub — source control for Terraform code
- Terraform Cloud — remote state, remote execution, workspaces, CI/CD
- Ansible — configuration management for EC2 instances
2. Working with Existing Resources — Import
Context — Brownfield vs Greenfield
In practice, introducing Terraform into an existing environment is the norm (brownfield). Already-deployed resources must be brought into Terraform management without destroying or recreating them.
Globomantics Environment Architecture
flowchart TB
subgraph vpc["VPC us-east-1 — 10.42.0.0/16"]
igw[Internet Gateway]
rt[Route Table]
sg["Security Group NoIngress"]
subgraph az1["AZ us-east-1a"]
subnet1["Public Subnet 10.42.10.0/24"]
end
subgraph az2["AZ us-east-1b"]
subnet2["Public Subnet 10.42.11.0/24"]
end
igw --> rt
rt --> subnet1
rt --> subnet2
end
The terraform import Command
# Basic syntax
terraform import <ADDR> <ID>
# Example — import an AWS VPC
terraform import aws_vpc.main vpc-0a1b2c3d4e5f
# Import a resource into a module
terraform import module.main.aws_vpc.this[0] vpc-0a1b2c3d4e5f
Import command process:
- Terraform updates the state with the resource at the specified address
- It does not generate the
resourceblock in the code — you must write it yourself - You run
terraform planto verify no changes are required - If changes appear, you fix the code until
No changes
The import Block (Terraform 1.5+)
The import block is the declarative, modern approach introduced in Terraform 1.5.
# imports.tf
# Import the VPC
import {
to = module.main.aws_vpc.this[0]
id = "vpc-0a1b2c3d4e5f"
}
# Import subnets
import {
to = module.main.aws_subnet.public[0]
id = "subnet-0a1b2c3d"
}
import {
to = module.main.aws_subnet.public[1]
id = "subnet-0e5f6g7h"
}
# Import a security group (outside module)
import {
to = aws_security_group.no_ingress
id = "sg-0a1b2c3d"
}
Automatic configuration code generation:
# Terraform generates the missing resource blocks in generated.tf
terraform plan -generate-config-out=generated.tf
# Once satisfied with the result, apply the import
terraform apply
import command vs import block Comparison
| Criterion | terraform import | import block |
|---|---|---|
| Version | All versions | Terraform 1.5+ |
| Execution plan | No | Yes |
| Code generation | No | Yes (-generate-config-out) |
| Reversible | No | Yes (remove the block) |
| Idempotent | No | Yes |
| VCS management | No | Yes |
| Multi-resource | One per command | Multiple blocks |
Usage in a Module
# resources.tf — using the vpc module from the public registry
module "main" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "globo-dev"
cidr = var.cidr_block
azs = data.aws_availability_zones.available.names
public_subnets = [for k, v in var.public_subnets : v.cidr_block]
tags = local.common_tags
}
# Resource outside module
resource "aws_security_group" "no_ingress" {
name = "no-ingress-sg"
description = "Security group that blocks all ingress"
vpc_id = module.main.vpc_id
tags = local.common_tags
}
3. Team Collaboration — State and Remote Backend
Terraform State Anatomy
The Terraform state is a JSON file that links configuration to real resources. It contains:
{
"version": 4,
"terraform_version": "1.5.0",
"resources": [
{
"module": "module.main",
"mode": "managed",
"type": "aws_vpc",
"name": "this",
"instances": [
{
"index_key": 0,
"attributes": {
"id": "vpc-0a1b2c3d",
"cidr_block": "10.42.0.0/16",
"tags": { "Environment": "globo-dev" }
}
}
]
}
],
"outputs": {
"vpc_id": {
"value": "vpc-0a1b2c3d",
"type": "string"
}
}
}
State Inspection Commands
# List all resources in the state
terraform state list
# Show details of a resource
terraform state show module.main.aws_vpc.this[0]
# Export the complete state as JSON
terraform state pull
# Move a resource (rename)
terraform state mv aws_instance.old aws_instance.new
# Remove a resource from state without destroying the real resource
terraform state rm aws_instance.obsolete
Deprecated Commands and Their Replacements
| Deprecated command | Recommended replacement |
|---|---|
terraform refresh | terraform plan -refresh-only / terraform apply -refresh-only |
terraform taint <resource> | terraform apply -replace="<resource>" |
terraform untaint <resource> | Modify the code, terraform apply |
terraform import <addr> <id> | import {} block in HCL |
Backends — Comparison
flowchart LR
subgraph local["Local Backend"]
direction TB
L1[terraform.tfstate]
L2[terraform.tfstate.backup]
end
subgraph remote["Remote Backends"]
direction TB
R1[Terraform Cloud / Enterprise]
R2[AWS S3 + DynamoDB]
R3[Azure Blob Storage]
R4[GCS]
R5[Consul]
end
subgraph features["Features"]
direction TB
F1[State Locking]
F2[Workspaces]
F3[Encryption]
F4[Access Control]
end
R1 -- "✅ All" --> features
R2 -- "✅ Locking via DynamoDB" --> features
local -- "❌ None" --> features
Backend Configuration — Syntax
# backend.tf — Classic S3 backend (with DynamoDB locking)
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
# backend.tf — Terraform Cloud with the cloud block (Terraform 1.2+)
terraform {
cloud {
organization = "deep-dive-globo"
workspaces {
name = "web-network-dev"
# Or use tags for multiple workspaces:
# tags = ["network", "development"]
}
}
}
Important: The
backendblock andcloudblock do not support variable interpolation. All values must be literals or passed via partial configuration atterraform inittime.
Partial Configuration — Dynamic Backend
# Provide values at init time (useful for pipelines)
terraform init \
-backend-config="bucket=my-state-bucket" \
-backend-config="key=prod/terraform.tfstate" \
-backend-config="region=us-east-1"
Remote State — Collaboration Architecture
sequenceDiagram
participant Dev1 as Developer 1
participant Dev2 as Developer 2
participant TFC as Terraform Cloud
participant AWS as AWS
Dev1->>TFC: terraform plan (acquires lock)
TFC->>AWS: Refresh state
AWS-->>TFC: Current attributes
TFC-->>Dev1: Plan (proposed changes)
Dev2->>TFC: terraform plan (while Dev1 is active)
TFC-->>Dev2: ❌ Error: State locked by Dev1
Dev1->>TFC: terraform apply (confirms)
TFC->>AWS: Apply changes
AWS-->>TFC: Confirmation
TFC->>TFC: Release lock
TFC-->>Dev1: ✅ Apply complete
Dev2->>TFC: terraform plan (retry, lock free)
TFC-->>Dev2: ✅ Plan available
4. CI/CD Pipeline with Terraform
Infrastructure as Code Principles in a Pipeline
When Terraform manages production workloads, software development principles apply:
- Source Control — all code in Git / GitHub
- Continuous Integration (CI) — automatic validation on each commit
- Continuous Delivery (CD) — automatic application after approval
Terraform Automation Considerations
# .gitignore — Files to exclude from version control
.terraform/
terraform.tfstate
terraform.tfstate.backup
*.tfvars # May contain secrets
crash.log
override.tf
override.tf.json
Exception: The
.terraform.lock.hclfile (provider lock file) must be committed to ensure provider version consistency across all users and build servers.
Environment Variables for Automation
# Tell Terraform it's in an automation context
export TF_IN_AUTOMATION=true
# Disable interactive prompts (pipeline cannot respond)
export TF_INPUT=false
# Logging level
export TF_LOG=INFO # ERROR | WARN | INFO | DEBUG | TRACE
export TF_LOG_PROVIDER=ERROR # Separate provider plugin logging
export TF_LOG_PATH=/tmp/terraform.log
# Plugin cache to speed up builds
export TF_PLUGIN_CACHE_DIR=/home/runner/.terraform.d/plugins
# Terraform variables directly as env vars (TF_VAR_ prefix)
export TF_VAR_region="us-east-1"
export TF_VAR_billing_code="8675309"
GitHub Actions — CI Workflow for Terraform
# .github/workflows/terraform.yml
name: Terraform CI
on:
push:
branches: ["**"]
env:
TF_LOG: INFO
TF_INPUT: false
jobs:
terraform:
name: Terraform Validate & Format
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: "~1.5"
- name: Terraform Init
run: terraform init -backend=false # No backend for CI only
- name: Terraform Format Check
id: fmt
run: terraform fmt -check -recursive
continue-on-error: true
- name: Terraform Validate
id: validate
run: terraform validate -no-color
if: success() || failure() # Runs even if fmt failed
Complete CI/CD Workflow with Terraform Cloud VCS
sequenceDiagram
participant Dev as Developer
participant GH as GitHub
participant GHA as GitHub Actions (CI)
participant TFC as Terraform Cloud (CD)
participant AWS as AWS
Dev->>Dev: git checkout -b feature/add-subnet
Dev->>Dev: Modifies HCL code
Dev->>GH: git push origin feature/add-subnet
GH->>GHA: Trigger push event
GHA->>GHA: terraform fmt -check
GHA->>GHA: terraform validate
GHA-->>GH: ✅ CI Passed
Dev->>GH: Creates Pull Request (feature → development)
GH->>TFC: Trigger Speculative Plan
TFC->>AWS: Refresh state
AWS-->>TFC: Current state
TFC-->>GH: Plan displayed in PR (read-only)
Note over Dev,GH: Team reviews the plan
Dev->>GH: Merge Pull Request
GH->>TFC: Trigger Standard Run
TFC->>TFC: terraform plan
TFC-->>Dev: ⏸️ Awaiting manual approval
Dev->>TFC: Approves the plan
TFC->>AWS: terraform apply
AWS-->>TFC: Resources created/modified
TFC-->>Dev: ✅ Apply complete
5. Supporting Multiple Environments
The Four Approaches Compared
quadrantChart
title Terraform Multi-Environment Approaches
x-axis "Low operational complexity" --> "High operational complexity"
y-axis "Low isolation" --> "High isolation"
quadrant-1 "Maximum separation"
quadrant-2 "Strong isolation, heavy management"
quadrant-3 "Simple but risky"
quadrant-4 "Good balance"
OSS Workspaces: [0.15, 0.25]
Separate Directories: [0.45, 0.65]
Git Branches: [0.55, 0.70]
Separate Repositories: [0.85, 0.85]
OSS Workspaces — State Isolation
# Create and navigate between workspaces
terraform workspace list
terraform workspace new development
terraform workspace select development
terraform workspace show # Shows the current workspace
# Use terraform.workspace in code
locals {
env_name = terraform.workspace
# Different configuration per environment
instance_type = {
default = "t3.micro"
development = "t3.small"
staging = "t3.medium"
production = "t3.large"
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = local.instance_type[terraform.workspace]
tags = {
Name = "${terraform.workspace}-web"
Environment = terraform.workspace
Workspace = terraform.workspace
}
}
Workspace Isolation — Terraform Cloud vs OSS
| Feature | OSS Workspaces | Terraform Cloud Workspaces |
|---|---|---|
| Separate state | ✅ | ✅ |
| Separate backend | ❌ (shared) | ✅ (isolated per workspace) |
| Access control | ❌ | ✅ (RBAC) |
| VCS integration | ❌ | ✅ (branch-based) |
| Per-workspace variables | ❌ | ✅ |
| Per-workspace credentials | ❌ | ✅ |
| Recommended use case | Short-lived tests | Long-term environments |
Separate Directories Approach
network_config/
├── environments/
│ ├── development/
│ │ ├── main.tf → calls the network module
│ │ ├── variables.tf
│ │ ├── terraform.tfvars → dev-specific values
│ │ └── backend.tf → S3 backend dev bucket
│ ├── staging/
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ └── production/
│ ├── main.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── modules/
└── vpc/
├── main.tf
├── variables.tf
└── outputs.tf
# environments/development/terraform.tfvars
region = "us-east-1"
environment = "development"
billing_code = "DEV-001"
cidr_block = "10.42.0.0/16"
public_subnets = {
"public-1" = { cidr_block = "10.42.10.0/24" }
"public-2" = { cidr_block = "10.42.11.0/24" }
}
Promoting Changes Between Branches
# 1. Develop on a feature branch
git checkout main
git pull
git checkout -b feature/add-third-subnet
# 2. Modify code
# ... HCL modifications ...
terraform fmt
terraform validate
# 3. Commit and push
git add .
git commit -m "Add third public subnet and billing_code tag"
git push -u origin feature/add-third-subnet
# 4. PR feature → development (triggers CI + Speculative Plan)
# 5. Merge after approval (triggers Apply in the dev workspace)
# 6. Promotion dev → staging
git checkout staging
git pull
# Create PR development → staging in GitHub
# 7. Promotion staging → production
# Create PR staging → production in GitHub
# 8. Sync main branch (source of truth)
# PR production → main
6. Connecting Multiple Configurations
Why Separate Configurations?
- Complexity — fewer resources per configuration = faster refresh
- Blast radius — a change cannot impact all infrastructure
- Separation of responsibilities — network team, security, application
Information Sharing Options
flowchart LR
subgraph net["Network Configuration"]
NO[Outputs:\nvpc_id\npublic_subnets]
end
subgraph options["Sharing Mechanisms"]
O1[terraform_remote_state\ndata source]
O2[tfe_outputs\ndata source]
O3[AWS SSM\nParameter Store]
O4[AWS Route 53\nTXT Records]
O5[Manual variables]
end
subgraph app["Application Configuration"]
AI[Inputs used]
end
net --> O1 --> app
net --> O2 --> app
net --> O3 --> app
net --> O4 --> app
net --> O5 --> app
style O2 fill:#00c853,color:#fff
style O1 fill:#ff6d00,color:#fff
terraform_remote_state — Full State Access
# Access the complete state of the network configuration
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "globo-terraform-state"
key = "network/development/terraform.tfstate"
region = "us-east-1"
}
}
# Usage
resource "aws_instance" "web" {
subnet_id = data.terraform_remote_state.network.outputs.public_subnets[0]
vpc_security_group_ids = [
data.terraform_remote_state.network.outputs.security_group_id
]
}
⚠️ Risk: This approach exposes the entire state (including sensitive data) to the calling configuration. Not recommended if teams don’t share the same access level.
tfe_outputs — Limited Access to Outputs (Terraform Cloud)
# terraform.tf — Add the TFE provider
terraform {
required_providers {
tfe = {
source = "hashicorp/tfe"
version = "~> 0.45"
}
}
}
# datasources.tf — Retrieve only the exposed outputs
data "tfe_outputs" "networking" {
organization = var.tfe_organization
workspace = var.tfe_workspace_name
}
# Non-sensitive outputs are in nonsensitive_values
locals {
vpc_id = data.tfe_outputs.networking.nonsensitive_values.vpc_id
public_subnets = data.tfe_outputs.networking.nonsensitive_values.public_subnets
}
7. Configuration Management with Ansible
Terraform Limitations for OS Configuration
Terraform operates on a Get → Test → Set cycle via provider APIs. For OS configuration or application deployment, no provider API is available. This is where a configuration management tool comes in.
Configuration Management Tools Comparison
| Tool | Model | Agent required | Language | Use case |
|---|---|---|---|---|
| Ansible | Push / Pull | No (SSH) | YAML | Ad-hoc config, deployment |
| Puppet | Pull | Yes | Puppet DSL | Large fleets, compliance |
| Chef | Pull | Yes | Ruby DSL | Existing Ruby environments |
| SaltStack | Push + Pull | Optional | YAML + Python | Large scale |
user_data + templatefile Pattern
# resources.tf — EC2 instance with Ansible user_data
resource "aws_instance" "web" {
count = length(local.public_subnets)
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = local.public_subnets[count.index]
vpc_security_group_ids = [aws_security_group.web.id]
iam_instance_profile = aws_iam_instance_profile.ec2_profile.name
# Trigger replacement if user_data changes
user_data_replace_on_change = true
# templatefile() reads the .tftpl file and substitutes variables
user_data = templatefile("${path.module}/templates/userdata.tftpl", {
ansible_repo_url = var.ansible_repo_url
playbook_path = "playbooks/webserver.yml"
environment = var.environment
})
tags = merge(local.common_tags, {
Name = "${var.prefix}-web-${count.index + 1}"
})
}
#!/bin/bash
# templates/userdata.tftpl
# Install dependencies
yum install -y git
amazon-linux-extras install ansible2 -y
# Clone the Ansible repository
git clone ${ansible_repo_url} /opt/ansible
cd /opt/ansible
# Run the playbook
ansible-playbook ${playbook_path} \
--connection=local \
-e "environment=${environment}"
The terraform_data Resource (Replacement for null_resource)
# OLD APPROACH — null_resource (deprecated)
resource "null_resource" "app_config" {
triggers = {
instance_ids = join(",", aws_instance.web[*].id)
}
provisioner "remote-exec" {
# ...
}
}
# NEW APPROACH — terraform_data (Terraform 1.4+)
resource "terraform_data" "app_config" {
# triggers_replace is a list (not a map like null_resource)
triggers_replace = [
join(",", aws_instance.web[*].private_dns),
var.site_name
]
# Store arbitrary data in the state
input = {
instance_count = length(aws_instance.web)
updated_at = timestamp()
}
provisioner "local-exec" {
command = "echo 'Config updated for ${self.input.instance_count} instances'"
}
}
null_resource vs terraform_data Comparison
| Aspect | null_resource | terraform_data |
|---|---|---|
| Provider required | null provider | Built-in Terraform |
triggers | map(string) | Arbitrary list |
| Data storage | No | input argument |
| Status | Deprecated | Active (Terraform 1.4+) |
| Plugin to download | Yes | No |
8. Managing Sensitive Data
Sensitive Data Lifecycle in Terraform
flowchart LR
subgraph inputs["Input data"]
V1[Input Variables\nsensitive = true]
V2[ENV Variables\nTF_VAR_*]
V3[.tfvars files\n⚠️ Do not commit]
end
subgraph processing["Terraform Processing"]
P1[HCL Configuration]
P2[Terraform State\n⚠️ Contains everything\nin plaintext]
P3[Console Output\nmasked if sensitive]
end
subgraph outputs["Outputs"]
O1[Output Values\nsensitive = true]
O2[Logs\nmasked if TF_LOG]
end
subgraph best["Best Practices"]
B1[AWS Secrets Manager]
B2[HashiCorp Vault]
B3[SSM Parameter Store]
B4[Machine Identity\nIAM Role]
end
inputs --> processing
processing --> outputs
best -.->|Prevent Terraform\nfrom touching secrets| processing
The sensitive Argument
# variables.tf — Mark a variable as sensitive
variable "api_key" {
type = string
description = "API key for the SaaS application"
sensitive = true # Masked in console outputs
}
variable "db_password" {
type = string
sensitive = true
}
# outputs.tf — Mark an output as sensitive
output "private_key_pem" {
value = tls_private_key.web.private_key_pem
sensitive = true # Doesn't display with terraform output
}
# To explicitly display a sensitive output:
# terraform output -raw private_key_pem
⚠️ Critical limitation:
sensitive = truedoes not prevent plaintext storage in Terraform state. It is only visual masking in the console.
AWS Secrets Manager — Recommended Architecture
sequenceDiagram
participant TF as Terraform
participant SM as AWS Secrets Manager
participant IAM as AWS IAM
participant EC2 as EC2 Instance
Note over TF,SM: Setup phase (one time)
TF->>SM: Creates the secret (only ID transmitted)
TF->>IAM: Creates IAM Role + Policy (GetSecretValue)
TF->>EC2: Launches instance with iam_instance_profile
TF->>EC2: Passes secret_id via input variable (not the value!)
Note over EC2,SM: Runtime phase (Terraform never sees the value)
EC2->>SM: GetSecretValue(secret_id)
SM->>IAM: Verifies EC2 role permissions
IAM-->>SM: Authorized
SM-->>EC2: Secret value (encrypted in transit)
EC2->>EC2: Uses the value locally
# create_secrets_manager/main.tf
resource "aws_secretsmanager_secret" "api_key" {
name = "${var.prefix}-api-key"
description = "API key for the SaaS application"
tags = var.common_tags
}
resource "aws_secretsmanager_secret_version" "api_key" {
secret_id = aws_secretsmanager_secret.api_key.id
secret_string = var.api_key # var.api_key is marked sensitive
}
# IAM Role for EC2 instances
resource "aws_iam_role" "ec2_role" {
name = "${var.prefix}-ec2-role"
assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
}
output "secret_id" {
value = aws_secretsmanager_secret.api_key.id
description = "Secret ID — to pass as input variable to the application"
# We pass the ID (not the value) to the application configuration
}
output "ec2_role_name" {
value = aws_iam_role.ec2_role.name
}
9. Reference — Terraform Functions
String Functions
| Function | Signature | Description | Example |
|---|---|---|---|
format | format(spec, args...) | Format a string | format("Hello, %s!", var.name) |
join | join(sep, list) | Join a list | join(",", ["a","b","c"]) → "a,b,c" |
split | split(sep, str) | Split a string | split(",", "a,b,c") → ["a","b","c"] |
replace | replace(str, sub, rep) | Replace | replace("abc", "b", "X") → "aXc" |
trimspace | trimspace(str) | Remove spaces | trimspace(" hello ") → "hello" |
lower / upper | lower(str) | Case | lower("HELLO") → "hello" |
substr | substr(str, off, len) | Substring | substr("hello", 1, 3) → "ell" |
regex | regex(pattern, str) | Regex | regex("[0-9]+", "abc123") |
templatefile | templatefile(path, vars) | File template | see below |
Collection Functions
| Function | Description | Example |
|---|---|---|
length | Length of a list/map | length(var.subnets) |
merge | Merge maps | merge(local.common_tags, {Name="web"}) |
concat | Concatenate lists | concat(list1, list2) |
flatten | Flatten nested lists | flatten([[1,2],[3,4]]) → [1,2,3,4] |
distinct | Remove duplicates | distinct(["a","b","a"]) → ["a","b"] |
toset | Convert to set | toset(["a","b","a"]) |
tolist | Convert to list | tolist(var.my_set) |
tomap | Convert to map | tomap({a="1", b="2"}) |
keys | Keys of a map | keys({a=1, b=2}) → ["a","b"] |
values | Values of a map | values({a=1, b=2}) → [1, 2] |
lookup | Value from a map | lookup(var.ami_map, var.region, "default") |
zipmap | Create map from two lists | zipmap(["a","b"], [1, 2]) → {a=1, b=2} |
element | Element by index (cyclic) | element(["a","b","c"], 5) → "c" |
slice | Sub-list | slice(["a","b","c","d"], 1, 3) → ["b","c"] |
index | Index of an element | index(["a","b","c"], "b") → 1 |
contains | Existence in a list | contains(["a","b"], "a") → true |
Numeric and Conditional Functions
| Function | Description | Example |
|---|---|---|
max / min | Maximum / Minimum | max(1, 5, 3) → 5 |
ceil / floor | Round up / down | ceil(1.2) → 2 |
abs | Absolute value | abs(-5) → 5 |
coalesce | First non-null | coalesce("", null, "found") → "found" |
coalescelist | First non-empty | coalescelist([], ["a","b"]) → ["a","b"] |
try | Evaluate without error | try(var.optional_config.key, "default") |
can | Test if evaluation possible | can(var.obj.key) → bool |
Type and Encoding Functions
| Function | Description |
|---|---|
jsonencode / jsondecode | JSON serialization |
yamlencode / yamldecode | YAML serialization |
base64encode / base64decode | Base64 encoding |
filebase64 | Read file and encode to Base64 |
file | Read a file as string |
filemd5 | MD5 hash of a file |
md5 / sha256 | Hash of a string |
tostring / tonumber / tobool | Type conversion |
type | Type of a value |
10. Reference — Meta-arguments
Meta-arguments Overview
mindmap
root((Terraform\nMeta-arguments))
count
Fixed number of instances
count.index for the index
for_each
Iterate over map or set
each.key and each.value
depends_on
Explicit dependencies
List of resources
lifecycle
create_before_destroy
prevent_destroy
ignore_changes
replace_triggered_by
provider
Select a provider
Provider alias
count — Multiple Instances by Index
# Create N identical instances
resource "aws_instance" "web" {
count = var.instance_count
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = var.public_subnets[count.index % length(var.public_subnets)]
tags = {
Name = "${var.prefix}-web-${count.index + 1}"
Index = count.index
}
}
# Reference a specific instance
output "first_instance_id" {
value = aws_instance.web[0].id
}
# Reference all instances (splat expression)
output "all_instance_ids" {
value = aws_instance.web[*].id
}
for_each — Multiple Instances by Key
# for_each on a map (recommended for stable resources)
variable "public_subnets" {
type = map(object({
cidr_block = string
availability_zone = string
}))
default = {
"public-1" = { cidr_block = "10.42.10.0/24", availability_zone = "us-east-1a" }
"public-2" = { cidr_block = "10.42.11.0/24", availability_zone = "us-east-1b" }
"public-3" = { cidr_block = "10.42.12.0/24", availability_zone = "us-east-1c" }
}
}
resource "aws_subnet" "public" {
for_each = var.public_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr_block
availability_zone = each.value.availability_zone
tags = {
Name = "${var.prefix}-${each.key}"
}
}
# for_each on a set of strings
resource "aws_iam_user" "team" {
for_each = toset(["alice", "bob", "carol"])
name = each.key # each.key == each.value for sets
path = "/team/"
}
lifecycle — Lifecycle Control
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
lifecycle {
# Create the new instance BEFORE destroying the old one
# Useful for zero-downtime deployments
create_before_destroy = true
# Prevent accidental destruction
# terraform destroy will return an error
prevent_destroy = true
# Ignore changes on certain attributes
# Useful when an app modifies tags outside of Terraform
ignore_changes = [
tags["LastModified"],
user_data,
]
# Replace this resource if another changes
replace_triggered_by = [
terraform_data.app_config.id,
aws_launch_template.web.latest_version
]
}
}
depends_on — Explicit Dependencies
# depends_on is needed when Terraform cannot automatically detect
# a dependency (e.g.: configuration dependencies, not data)
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
# Instance must wait for routing and gateway to be ready
# even if it doesn't directly reference these resources
depends_on = [
aws_route.public_internet,
aws_internet_gateway_attachment.main
]
}
provider — Provider Alias for Multi-Region / Multi-Account
# Default provider
provider "aws" {
region = "us-east-1"
}
# Alternative provider with alias
provider "aws" {
alias = "eu_west"
region = "eu-west-1"
}
provider "aws" {
alias = "prod_account"
region = "us-east-1"
profile = "production"
}
# Use a specific provider in a resource
resource "aws_instance" "eu_server" {
provider = aws.eu_west
ami = data.aws_ami.amazon_linux_eu.id
# ...
}
# Pass a provider to a module
module "network_eu" {
source = "./modules/network"
providers = {
aws = aws.eu_west
}
}
11. Reference — Terraform Environment Variables
| Variable | Description | Values |
|---|---|---|
TF_IN_AUTOMATION | Automation mode | Any value |
TF_INPUT | Disable interactive prompts | false |
TF_LOG | Terraform core logging level | ERROR, WARN, INFO, DEBUG, TRACE |
TF_LOG_PROVIDER | Provider logging level | Same |
TF_LOG_PATH | Log file | Absolute path to a file |
TF_PLUGIN_CACHE_DIR | Plugin cache | Path to a directory |
TF_VAR_<name> | Variable value | Variable’s value |
TF_CLI_ARGS | Global CLI arguments | e.g.: -no-color |
TF_CLI_ARGS_plan | Arguments for terraform plan | e.g.: -refresh=false |
TF_DATA_DIR | .terraform directory | Alternative path |
TF_WORKSPACE | Active workspace | Workspace name |
TF_REGISTRY_DISCOVERY_RETRY | Registry discovery retry | Number |
12. Advanced HCL Patterns
Dynamic Blocks
dynamic blocks allow programmatic generation of configuration blocks, avoiding repetition in resources that accept repeated nested blocks.
# Example — Security Group with dynamic rules
variable "ingress_rules" {
type = list(object({
port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = [
{ port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTP" },
{ port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTPS" },
{ port = 22, protocol = "tcp", cidr_blocks = ["10.0.0.0/8"], description = "Internal SSH" },
]
}
resource "aws_security_group" "web" {
name = "${var.prefix}-web-sg"
description = "Security group for web servers"
vpc_id = var.vpc_id
# Dynamic block for ingress rules
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = local.common_tags
}
for Expressions and Collection Transformations
locals {
# for expression on a list → list
upper_names = [for name in var.names : upper(name)]
# for expression with condition (filter)
large_instances = [
for inst in var.instances : inst
if inst.instance_type != "t3.micro"
]
# for expression on a map → map
instance_ids = {
for k, v in aws_instance.web : k => v.id
}
# Transform a list to a map (key = value)
subnet_map = {
for subnet in aws_subnet.public : subnet.availability_zone => subnet.id
}
# Group by a property (for expression with ellipsis)
instances_by_az = {
for inst in aws_instance.web :
inst.availability_zone => inst.id...
}
}
Variable Validation
variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["development", "staging", "production"], var.environment)
error_message = "Environment must be development, staging, or production."
}
}
variable "cidr_block" {
type = string
description = "CIDR block for the VPC"
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Value must be a valid CIDR block (e.g.: 10.0.0.0/16)."
}
}
variable "instance_count" {
type = number
description = "Number of EC2 instances"
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "Instance count must be between 1 and 10."
}
}
moved Block — Refactoring Without Destruction
# When renaming a resource, Terraform wants to destroy and recreate it
# The moved block allows clean refactoring
moved {
from = aws_security_group.no_ingress
to = aws_security_group.baseline
}
# Move from root to a module
moved {
from = aws_vpc.main
to = module.network.aws_vpc.this
}
# Move from a count resource to for_each
moved {
from = aws_instance.web[0]
to = aws_instance.web["primary"]
}
Sources and additional resources
- Terraform Registry — Official provider and module documentation
- Terraform Cloud — HashiCorp platform for state management and CI/CD
- Deep-Dive-Terraform GitHub — Course exercises
- HashiCorp Learn — Official tutorials
- Certification: HashiCorp Certified: Terraform Associate — This course covers advanced certification topics
Search Terms
terraform · deep · dive · infrastructure · ci/cd · devops · state · configuration · functions · architecture · comparison · import · backend · block · cloud · environment · reference · sensitive · access · automation · collaboration · collection · command · commands