I’ve always found Terraform meta arguments a bit confusing at first glance—not count
, for_each
, but things like connection
, provisioner
, depends_on
, source
and lifecycle
often seem straightforward but can behave unexpectedly in different contexts. That’s why I decided to write this blog post: to break them down clearly, explain what each one does, and show practical examples of how and when to use them effectively.
Terraform Meta Arguments Table
Meta-Argument | Applicable To | Description |
---|---|---|
count |
resource , module , data |
Create multiple instances of a resource or module using a number. |
for_each |
resource , module , data |
Create multiple instances using a map or set of strings. More flexible than count. |
provider |
resource |
Specify which provider configuration to use if multiple are defined. |
depends_on |
resource , module , data |
Explicitly define dependencies between resources or modules. |
lifecycle |
resource |
Control resource creation and destruction behavior (e.g., prevent_destroy, ignore_changes). |
provisioner |
resource |
Run scripts or commands after a resource is created or destroyed. |
connection |
resource |
Define how to connect to a remote resource (used with provisioners). |
source |
module |
Specify the location of a module (registry, Git, local path, etc.). |
Usage
🧮 Terraform meta arguments: count
resource "aws_instance" "web" { count = 3 ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" tags = { Name = "Web-${count.index}" } }
✅ Creates multiple instances using a simple integer.
⚠️ Attention Notes:
-
count.index
starts from0
. -
Not ideal for working with named collections (use
for_each
instead). -
Not supported in
provider
blocks.
🔁 Terraform meta arguments: for_each
resource "aws_s3_bucket" "example" { for_each = toset(["logs", "media", "backups"]) bucket = "my-bucket-${each.key}" acl = "private" }
✅ More flexible than count
, supports map and set types.
⚠️ Attention Notes:
-
each.key
andeach.value
used depending on collection type. -
Keys must be unique.
-
Best for managing multiple named resources (e.g., per environment).
🧩 Terraform meta arguments: provider
provider "aws" { region = "us-east-1" alias = "east" } provider "aws" { region = "us-west-2" alias = "west" } resource "aws_instance" "example" { provider = aws.west ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" }
✅ Specifies a particular provider config when multiple are defined.
⚠️ Attention Notes:
-
Only works in
resource
, notmodule
blocks. -
Must use
alias
to differentiate provider instances if have the same names.
🔗 Terraform meta arguments: depends_on
resource "aws_iam_role" "role" { name = "example-role" assume_role_policy = jsonencode({ Version = "2012-10-17", Statement = [{ Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" }, Action = "sts:AssumeRole" }] }) } resource "aws_iam_role_policy_attachment" "attachment" { role = aws_iam_role.role.name policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" depends_on = [aws_iam_role.role] }
✅ Ensures ordering when Terraform can't automatically infer it.
⚠️ Attention Notes:
-
Use when a dependency is implicit (e.g.,
local-exec
, provisioners). -
Can be used in
resource
,module
, anddata
blocks.
♻️ Terraform meta arguments: lifecycle
resource "aws_instance" "db" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t3.micro" lifecycle { prevent_destroy = true create_before_destroy = true ignore_changes = [tags["Owner"]] } }
Lifecycle Argument | Default | Description |
---|---|---|
create_before_destroy |
false |
Ensures a new resource is created before the old one is destroyed to avoid downtime. Common in zero-downtime deployments. |
prevent_destroy |
false |
Prevents a resource from being destroyed. Terraform will produce an error if a destroy is attempted on this resource. |
ignore_changes |
[] |
Ignores changes to specific attributes in future plans. Useful for fields updated externally or during auto-scaling. |
replace_triggered_by |
[] |
Forces resource replacement when another referenced resource or attribute changes. Introduced in Terraform 0.15+ |
✅ Fine-tunes how Terraform handles changes and destruction.
⚠️ Attention Notes:
-
prevent_destroy
helps protect critical infra. -
ignore_changes
avoids re-creating resources when certain fields change. -
Only supported in
resource
blocks.
💻 Terraform meta arguments: provisioner
resource "null_resource" "example" { provisioner "local-exec" { command = "echo Hello, Terraform!" } }
✅ Executes a script or command after resource creation.
⚠️ Attention Notes:
-
Best for ad-hoc automation or external configuration steps.
-
Two types:
local-exec
andremote-exec
. -
Not idempotent—Terraform can't track what was done.
🔐 Terraform meta arguments: connection
resource "null_resource" "remote" { provisioner "remote-exec" { inline = ["echo Connected!"] } connection { type = "ssh" user = "ubuntu" host = "1.2.3.4" private_key = file("~/.ssh/id_rsa") } }
✅ Used with remote-exec
provisioners to connect to VMs or servers.
⚠️ Attention Notes:
-
Only works inside a
resource
block. -
Requires credentials and reachable IP.
- Supported ssh and WinRM
-
Mostly used in VM provisioning, not cloud-native workflows.
📦 Terraform meta arguments: source (for modules)
module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 4.0" }
✅ Tells Terraform where to find the module (registry, Git, local, etc.).
⚠️ Attention Notes:
-
Only valid in
module
blocks. - the
source
argument in a Terraformmodule
block does not support dynamic expressions like variables. It must be a static, known-at-plan-time string. -
When using the registry, you can also set a
version
.
Advanced Usage
This example is to help me to understand what is a module, how the variables passing between root module and child modules, advanced usage of count
, how to bypass source
limit that it cannot use variables, usage of null_resource
and local-exec
usage.
Directory Structure
❯ tree . ├── main.tf ├── modules │ ├── aws_module │ │ └── main.tf │ ├── azure_module │ │ └── main.tf │ └── wrapper │ ├── main.tf │ └── variables.tf └── variables.tf
Code Walkthrough
❯ cat main.tf module "cloud_infra" { source = "./modules/wrapper" provider_type = var.provider_type } ❯ cat variables.tf variable "provider_type" { description = "Which cloud provider to use: 'aws' or 'azure'" type = string default = "aws" } ❯ cat modules/wrapper/main.tf module "aws" { source = "../aws_module" count = var.provider_type == "aws" ? 1 : 0 } module "azure" { source = "../azure_module" count = var.provider_type == "azure" ? 1 : 0 } ❯ cat modules/wrapper/variables.tf variable "provider_type" { description = "Cloud provider type: aws or azure" type = string } ❯ cat modules/aws_module/main.tf resource "null_resource" "aws_example" { provisioner "local-exec" { command = "echo Deploying AWS Infrastructure" } } ❯ cat modules/azure_module/main.tf resource "null_resource" "azure_example" { provisioner "local-exec" { command = "echo Deploying Azure Infrastructure" } }
Takeaways
This is just an example to illustrate the usage of a wrapper module in Terraform, but it’s also grounded in practical value you’d encounter in real-world scenarios.
-
Modular Abstraction
-
The root module only needs to know what to deploy, not how it's deployed for each provider.
-
You use a variable (e.g.
provider_type
) to switch between providers.
-
-
Wrapper Logic in Its Own Module
-
The wrapper module decides which provider-specific module to load based on the input value.
-
This avoids conditional logic spread across the root module.
-
-
Directory Structure Reflects Cloud Providers
-
Each cloud (AWS, Azure, etc.) has its own submodule with isolated logic.
-
This keeps code clean and avoids mixing different cloud resources in the same files.
-
-
Dynamic Module Source Selection
-
By using a variable in the module
source
path and combine withcount
, you can dynamically load the desired submodule (aws
,azure
, etc.). -
This is static at plan/apply time, but flexible from a design perspective.
-
-
Encapsulation of Variables
-
Variables like
provider_type
are declared at every module level that needs them. -
This ensures smooth flow of configuration down from root module → wrapper → cloud module.
-
-
Easy to Extend
-
Adding support for a new cloud provider (e.g. GCP) is as simple as adding a new folder and extending the wrapper logic — no changes needed in the root module.
-
Summary
Terraform meta arguments—like lifecycle
, and provisioner
—can seem straightforward until you hit real-world use cases. In this post, I broke down each of these Terraform meta arguments with clear explanations, practical examples, and some gotchas to watch out for. I also shared a working demo using a wrapper module pattern to dynamically deploy AWS or Azure modules based on input variables. Whether you're new to Terraform modules or just want to sharpen your understanding of Terraform meta arguments behaviors, this guide aims to bring clarity to the chaos.
🚀 In Part 1, I laid out the networking plan, my goals for setting up Kubernetes, and how to prepare a base VM image for the cluster.
🚀 In Part 2, I walked through configuring a local DNS server and NTP server, essential for stable name resolution and time synchronization across nodes locally. These foundational steps will make our Kubernetes setup smoother
🚀 In Part 3, I finished the Kubernetes cluster setup with Flannel, got one Kubernetes master and 4 worker nodes that’s ready for real workloads.
🚀 In Part 4, I explored NodePort and ClusterIP,understood the key differences, use cases, and when to choose each for internal and external service access!🔥
🚀 In Part 5, I dived into ExternalName
and LoadBalancer
services, uncovering how they handle external access, DNS resolution, and dynamic traffic distribution!