Terraform Tips from the IaC Trenches

Source: Dev.to | Dec 16, 2025 (UTC)

After a few years of writing open-source Terraform modules, I’ve picked up a few syntax tricks that make code safer, cleaner, and easier to maintain. These aren’t revolutionary, but they’re simple patterns that prevent common mistakes and make the infrastructure more resilient. Based on the configurations I’ve seen in the wild, these techniques seem to be underutilized.

Use one() for Safer Conditional Resource References

When you conditionally create resources with count, don’t reach for [0] — use one().

The Problem

It’s common to use count with a boolean to conditionally create resources (especially in open-source modules that accommodate a lot of different configuration settings):

data "aws_route53_zone" "this" { count = var.create_dns ? 1 : 0 name = "rosesecurity.dev" } resource "aws_route53_record" "this" { zone_id = data.aws_route53_zone.this[0].zone_id # ❌ Dangerous name = "blog.rosesecurity.dev" type = "A" # ... }

Enter fullscreen mode

Exit fullscreen mode

This looks fine and might even work in dev environments where var.create_dns = true. But the moment that variable is false in another environment, you get:

Error: Invalid index The given key does not identify an element in this collection value: the collection value is an empty tuple.

Enter fullscreen mode

Exit fullscreen mode

The issue? This fails at runtime, not plan time. The code works when the resource exists and breaks when it doesn’t.

The Solution

Use one() with the [*] splat operator:

data "aws_route53_zone" "this" { count = var.create_dns ? 1 : 0 name = "rosesecurity.dev" } resource "aws_route53_record" "this" { zone_id = one(data.aws_route53_zone.this[*].zone_id) # ✅ Safe(r) name = "blog.rosesecurity.dev" type = "A" # ... }

Enter fullscreen mode

Exit fullscreen mode

The one() function (available in Terraform v0.15+) is designed for this exact pattern:

When you use [0], you’re assuming the resource exists. When you use one(), you’re validating it exists.

Bonus: one() also works with sets, which don’t support index notation at all. Using one() makes the code more versatile and future-proof.

Design Better Module Variables with Objects, optional(), and coalesce()

When building reusable Terraform modules, variable design makes the difference between a module that’s fun to use and one that’s a configuration nightmare. Here’s a pattern that combines several Terraform features to create flexible, well-documented, and maintainable module interfaces.

The Problem: Scattered Variables

Most modules start simple and grow organically, leading to an explosion of individual variables:

# ❌ Scattered variables - hard to manage and document variable "elasticsearch_subdomain_name" { type = string description = "The name of the subdomain for Elasticsearch" } variable "elasticsearch_port" { type = number description = "Port for Elasticsearch" default = 9200 } variable "elasticsearch_enable_ssl" { type = bool description = "Enable SSL for Elasticsearch" default = true } variable "kibana_subdomain_name" { type = string description = "The name of the subdomain for Kibana" default = null } variable "kibana_port" { type = number description = "Port for Kibana" default = 5601 } variable "kibana_enable_ssl" { type = bool description = "Enable SSL for Kibana" default = true } # ... and on and on for 12+ more variables

Enter fullscreen mode

Exit fullscreen mode

This gets unwieldy fast. Users have to understand which variables are related, documentation becomes repetitive, and adding a new service means adding another set of scattered variables.

The Solution: Group Related Variables into Objects

Use objects with the optional() function to group logically related settings:

# ✅ Grouped by logical component variable "elasticsearch_settings" { type = object({ subdomain_name = optional(string) port = optional(number, 9200) enable_ssl = optional(bool, true) }) description = <<-DOC Configuration settings for Elasticsearch service. subdomain_name: The name of the subdomain for Elasticsearch in the DNS zone (e.g., 'elasticsearch', 'search'). Defaults to environment name. port: Port number for Elasticsearch. Defaults to 9200. enable_ssl: Enable SSL/TLS for Elasticsearch. Defaults to true. DOC default = {} } variable "kibana_settings" { type = object({ subdomain_name = optional(string) port = optional(number, 5601) enable_ssl = optional(bool, true) }) description = <<-DOC Configuration settings for Kibana service. subdomain_name: The name of the subdomain for Kibana in the DNS zone (e.g., 'kibana', 'ui'). Defaults to environment name. port: Port number for Kibana. Defaults to 5601. enable_ssl: Enable SSL/TLS for Kibana. Defaults to true. DOC default = {} }

Enter fullscreen mode

Exit fullscreen mode

The optional() function (Terraform v1.3+) lets you define object attributes that users can omit:

subdomain_name = optional(string) # Can be omitted, defaults to null port = optional(number, 9200) # Can be omitted, defaults to 9200 enable_ssl = optional(bool, true) # Can be omitted, defaults to true

Enter fullscreen mode

Exit fullscreen mode

This means users can provide as much or as little configuration as they need:

# Minimal - just override subdomain elasticsearch = { subdomain_name = "search" # port and enable_ssl use defaults } # Or provide nothing, use all defaults elasticsearch = {} # Or customize everything elasticsearch = { subdomain_name = "es-prod" port = 9300 enable_ssl = false }

Enter fullscreen mode

Exit fullscreen mode

HEREDOC Syntax for Documentation

Use indented HEREDOC (<<-DOC) to document complex object variables:

description = <<-DOC Configuration settings for Elasticsearch service. subdomain_name: The name of the subdomain for Elasticsearch in DNS. port: Port number for Elasticsearch. Defaults to 9200. enable_ssl: Enable SSL/TLS. Defaults to true. DOC

Enter fullscreen mode

Exit fullscreen mode

Why the dash matters:

The indented version plays nicely with automatic documentation generators like terraform-docs, producing clean, readable output in your README.

Smart Defaults with coalesce() and Context

Combine objects with the Terraform null label pattern (context.tf) to provide intelligent defaults:

# Use locals to apply coalesce logic locals { elasticsearch_subdomain = coalesce(var.elasticsearch.subdomain_name, module.this.environment) kibana_subdomain = coalesce(var.kibana.subdomain_name, module.this.environment) } # Resources reference the locals resource "aws_route53_record" "elasticsearch" { zone_id = var.zone_id name = "${local.elasticsearch_subdomain}.rosesecurity.dev" type = "CNAME" records = [aws_elasticsearch_domain.this.endpoint] ttl = 300 } resource "aws_route53_record" "kibana" { zone_id = var.zone_id name = "${local.kibana_subdomain}.rosesecurity.dev" type = "CNAME" records = [aws_elasticsearch_domain.this.kibana_endpoint] ttl = 300 }

Enter fullscreen mode

Exit fullscreen mode

The coalesce() function returns the first non-null value, giving you:

Without user input (in “prod” environment):

With user override:

elasticsearch = { subdomain_name = "search" }

Enter fullscreen mode

Exit fullscreen mode

Results in: search.prod.rosesecurity.dev

Let users configure only what matters, default the rest.

Group related variables into objects, use optional() for flexibility, document with indented HEREDOCs, and combine with coalesce() for intelligent defaults. Your module users will thank you.

Avoid Double Negatives in Variable Names

Boolean variables with negative names add unnecessary mental overhead. Positive variable names make conditional logic clearer and reduce the chance of configuration mistakes.

The Problem

# ❌ Negative variable name variable "disable_encryption" { description = "Disable encryption" type = bool default = false } resource "aws_s3_bucket_server_side_encryption_configuration" "this" { count = var.disable_encryption ? 0 : 1 bucket = aws_s3_bucket.this.id # ... }

Enter fullscreen mode

Exit fullscreen mode

The count line requires mental translation: “If disable_encryption is false, then count is 1, so encryption is enabled.” That’s a double negative in what should be straightforward logic.

This pattern creates real problems during code review. A change from default = false to default = true looks like it’s “enabling” something when it’s actually doing the opposite.

The Solution

# ✅ Positive variable name variable "encryption_enabled" { description = "Enable encryption" type = bool default = true } resource "aws_s3_bucket_server_side_encryption_configuration" "this" { count = var.encryption_enabled ? 1 : 0 bucket = aws_s3_bucket.this.id # ... }

Enter fullscreen mode

Exit fullscreen mode

The logic now reads directly: “If encryption_enabled is true, create the encryption config.”

Positive naming also makes security choices more explicit. Setting encryption_enabled = false is visually clearer than disable_encryption = true, even though they’re functionally equivalent.

Name variables for what they enable, not what they prevent.

If you liked (or hated) this blog, feel free to check out my GitHub!

For further actions, you may consider blocking this person and/or reporting abuse

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Читайте також