- The CI/CD Guy
- Posts
- Declarative Doesn’t Mean Dumb: Terraform’s Expression Engine, Unpacked
Declarative Doesn’t Mean Dumb: Terraform’s Expression Engine, Unpacked

There’s always more to discover — if you’re just willing to dig a little deeper.
And the same holds true for Terraform.
Sure, everyone knows the basics of Terraform (and if you don’t, don’t worry — we’ll be starting a series on that soon!). Go to the Terraform Registry, copy and paste the resource configuration, run a terraform init
, plan
, apply
— and boom, your resource is created.
Yay! You know Terraform now. Time to throw it on your résumé and start applying for DevOps roles, right?
Well… I wish it were that simple. AI has definitely brought us closer to that plug-and-play reality — but we’re not quite there yet. So Let’s keep learning…
Terraform Expressions and Functions
So, Terraform—while already a pro at handling the basic Infrastructure as Code (IaC) operations—is far more capable than just provisioning EC2 instances or creating VPCs. It’s equipped to handle advanced workflows, especially when you combine its built-in expressions and functions.
Let’s dive into these concepts, starting from the basics and moving toward a real-world use case.
1. Expressions in Terraform
Simply put, in Terraform, an expression is anything that returns a value. This includes,
Variables:
var.region
Functions:
format()
,join()
, etc.Arithmetic:
1 + 2
Conditionals:
condition ? true_val : false_val
Loops:
for <item> in <list>
Example: Conditional Instance Type Based on Environment
Say you need to use a different EC2 instance type depending on the environment. You can use a conditional expression like this:
resource "aws_instance" "this" {
...
instance_type = var.environment != "production" ? "t3.small" : "t3.2xlarge"
...
}
This line checks the value of the environment
variable and selects the appropriate instance type accordingly. Simple, yet powerful.
Here’s another example of an expression, using multiple functions(format
, lower
, title
) and a conditional:
output "personalized_greeting" {
value = format(
"Good %s, %s!",
lower(var.time_of_day) == "morning" ? "morning" : "evening",
title(var.first_name)
)
}
format
- Just like a print function in pythonlower
- You’re smart enough to guess this. But just for my revision, this is to change the string to lowercasetitle
- Converts first letter of each word to uppercase
2. Terraform Functions
A few of them you’ve already encountered in the previous section. These are functions—just like in any other programming language—that take some input and produce an output. In Terraform, their main purpose is to allow you to transform or manipulate values before using them in your configuration. Terraform has a rich library of built-in functions, including:
String functions (
format
,replace
,split
,trim
)Collection functions (
flatten
,merge
,lookup
,zipmap
)Encoding functions (
base64encode
,jsonencode
,yamldecode
)And many more...
These functions are crucial for writing clean, DRY (Don’t Repeat Yourself) Terraform code that adapts to different environments and configurations.
🧠 Combining Expressions and Functions: Real-World Example
Individually, expressions and functions might seem basic. But when you combine them with templating, directory-based automation, dynamic blocks, and other features that Terraform offers, they unlock a whole new level of power.
⚙️ Use Case: Creating a Developer Platform
Imagine you're building a self-service Developer platform that serves multiple teams, such as Data Engineering, MLOps, and WebApp. Each team has its own infrastructure requirements. Instead of manually writing Terraform resources for each request, you decide to standardise and automate the entire process.
Your idea: allow developers to simply provide configuration files specifying the resources they need. Your Terraform-based platform will take care of the rest using expressions, functions, and other divine Terraform powers.
🗂️ Directory Structure
├── configurations/
│ └── buckets/
│ ├── bucket_template.yaml.sample(created by DevOps)
│ ├── gcb_logs.yaml(created by Devs)
│ └── application_events.yaml(created by Devs)
│ └── bq_datasets/
│ └── workflows/
└── iac/(created by DevOps)
└── buckets.tf
└── bq_dataset.tf
└── workflow.tf
Explanation:
1. DevOps will set up iac
with all the Terraform resource configurations requested by the dev teams. → buckets.tf example explained below
2. DevOps will also set up a configurations directory, which will contain a directory for each of the resources defined within iac directory(buckets, artifact_repository, topics etc.), and a sample configuration file for each resource type(.sample), to guide developers with all the configurable properties of the resource they are creating.
# The name of the bucket is generated from the file.
# This sample does not require any resource to be deployed.
location: EU # Location of the bucket.
expiration_in_days: 10
force_destroy: false
3. Devs will come and, based on their requirement, will create a configuration file within the appropriate resource directory under configurations.
# file: configurations/buckets/gcb_logs.yaml
location: US
force_destroy: true
# file: configurations/buckets/application_events.yaml
location: EU
force_destroy: false
Now comes the magic inside the Terraform configuration files. For our example, let’s explore buckets.tf
locals {
buckets_folder = "${path.module}/../configurations/buckets"
bucket_raw_configs = {
for file_path in fileset(local.buckets_folder, "**/*.yaml") :
trimsuffix(basename(file_path), ".yaml") => merge(
{
file_stem = trimsuffix(basename(file_path), ".yaml")
},
yamldecode(file("${local.buckets_folder}/${file_path}"))
)
}
bucket_map = {
for file_stem, conf in local.bucket_raw_configs :
file_stem => {
file_stem = conf.file_stem
location = lookup(conf, "location", "EU")
force_destroy = lookup(conf, "force_destroy", true)
}
}
}
resource "google_storage_bucket" "buckets" {
for_each = local.bucket_map
name = "gcs-${each.value.file_stem}-${lower(each.value.location)}"
location = each.value.location
force_destroy = each.value.force_destroy
# Optional: add lifecycle rules, logging, IAM bindings, etc.
}
Magic Trick:
1. We are looping through all the configuration files present within the buckets directory
2. Parsing the content of the config files, doing transformations, and finally slotting all the relevant values to be used by our resource definitions.
🔄 What’s Happening Under the Hood?
fileset()
retrieves all.yaml
files from the directory.for
enables to loop through all the config fileyamldecode()
parses the configuration YAMLs into a map.merge()
combines default metadata likefile_stem
with user-defined values in the configuration fileslookup()
ensures safe access to keys, even if they are missing. and finally,for_each
in the resource, dynamically creates buckets per config.
✅ Benefits of This Approach
Scalability: Add a new bucket by simply creating a YAML file—no need to touch
.tf
files.Flexibility: Teams can define their own infra needs declaratively.
Maintainability: Centralized IaC logic reduces duplication and errors.
This pattern can be extended to support more complex resources, like IAM roles, service accounts, Pub/Sub topics, or any resource you can think of. The goal is to abstract complexity and empower developers without needing them to learn Terraform in depth.
Let me know if you'd like to see this idea extended to other resource types or want help building your own IaC framework around it!