Table of Contents
One of the biggest selling points of Infrastructure as Code is the promise of reusability. Write once, deploy everywhere. It’s elegant in theory. In practice, I see teams consistently sabotage this benefit by making the same preventable mistakes over and over. The result? Code that’s harder to maintain, security vulnerabilities, and deployments that fail in ways nobody saw coming.
Here are the five most common mistakes I encounter, and more importantly, how to avoid them.
1. Not Making Your Code Reusable
Let me start with a quick example. Here’s a Terraform resource that deploys a storage account:
resource "azurerm_storage_account" "storage_account" {
name = "examplestorageaccount"
resource_group_name = "example-resource-group"
location = "eastus"
account_tier = "Standard"
account_replication_type = "LRS"
}
There’s nothing fundamentally broken about this code. It will deploy a storage account. But here’s the problem: it won’t deploy anywhere else. Everything is hardcoded.
Now imagine you need to deploy the same infrastructure to another environment—maybe prod instead of dev, or West Europe instead of East US. You have two options: copy and manually edit the values, or maintain two versions of the same code. Neither is acceptable at scale.
The fix is straightforward: parameterise (most things).
Use variables to capture the values that change between deployments. It’s not just about reusability across environments either. When you parameterise your code, you make it composable. You can call the same module from different parent configurations, combine it with other resources, and build larger systems without duplicating logic.
The cost is minimal: a few extra variables and some thought about defaults. The benefit is that your code becomes a building block instead of a one-off script.
2. Misunderstanding Variables
Variables are one of Terraform’s most powerful features, but I consistently see them misused in two opposite ways: either they’re declared poorly (missing crucial properties), or every possible parameter is exposed as a variable (defeating the point of having opinionated infrastructure).
Let’s talk about the anatomy of a well-defined variable. When you declare a variable, you need at least four properties:
Name
The name must be unique within your configuration and should be descriptive. Don’t call it resource_name call it storage_account_name. When someone reads your code six months from now (possibly you), they should immediately understand what it controls.
Description
This is optional, but you should always include it. It’s documentation. It tells the consumer what the variable does and why they might want to change it. More importantly, if you’re building modules that other teams will use, the description is how they learn what values to pass.
Type
Defining the type prevents silent failures. Without it, Terraform infers the type, which can lead to unexpected behaviour. If you expect a list but someone passes a string, you want to fail loudly at plan time, not during apply. Better yet, you catch it immediately.
variable "storage_account_name" {
description = "Name of the storage account. Must be 3-24 lowercase alphanumeric characters."
type = string
}
Default Value
This is where I see teams trip up. Not setting a default value means your code will fail if the variable isn’t provided at runtime. Sometimes that’s intentional—if a value is truly required, make it fail. But most of the time, you should have a sensible default.
Think about this: variables exist to let you customise your deployment. But if you have to specify every variable every time, you’ve just created busy work. A good default means that simple deployments work without configuration, while complex ones can override what they need to.
For example, if you’re deploying a VM, maybe you have a default SKU size that works for most cases:
variable "vm_sku" {
description = "The SKU size for the virtual machine"
type = string
default = "Standard_D4s_v3"
}
Now, if you need a larger VM in prod, you override it. Otherwise, the deployment just works.
The .tfvars Pattern
Create separate .tfvars files for each environment or region: dev.tfvars, prod.tfvars, westeurope.tfvars. Only define the values you need to change. If a default value already meets your requirements, don’t explicitly set it.
Here’s what I see teams do wrong: they create a parent module that calls other modules, and they replicate every variable from the child modules as variables in the parent. This is pointless complexity. If a child module’s default works for you, don’t surface it as a variable. You’re just adding noise.
Using Validation Rules
Terraform allows you to add validation constraints to variables. This catches bad inputs before they become bad infrastructure.
Storage account names have strict rules: 3-24 characters, lowercase alphanumeric only. Or maybe you have a set of approved environment names: dev, test, uat, prod. These are perfect use cases for validation:
variable "environment" {
description = "The deployment environment"
type = string
validation {
condition = contains(["dev", "test", "uat", "prod"], var.environment)
error_message = "Environment must be one of: dev, test, uat, prod."
}
}
This prevents someone from accidentally deploying to an environment that doesn’t exist. Validation rules are configured at the variable scope, and there’s comprehensive documentation in the Terraform language reference if you need more complex constraints.
The Anti-Pattern: Too Many Variables
On the flip side, I’ve seen modules that expose every single configurable parameter as a variable. This defeats the purpose of having opinionated infrastructure.
Here’s the principle: your modules should encode your company’s best practices and policy. If your organisation’s policy is that all storage accounts must disable public access, then enforce that in the module. Don’t make it optional. Don’t create a variable allow_public_access and set it to false by default, hoping people won’t override it. Instead, remove the dial entirely.
Opinionated infrastructure is easier to maintain, more secure by default, and simpler to use. You offer customisation where it matters, and you enforce policy where it should be non-negotiable.
3. Writing Monolithic Code
I blame the documentation for this one. Terraform tutorials introduce the concept of a main.tf file, and teams run with it. They put everything—every resource, every variable, every output, every provider block—into a single file. Technically, Terraform will load any .tf file in the working directory, so this works. It’s just unmanageable.
When you have 500 lines of code in a single file, finding a specific resource means scrolling, searching, and losing context. Reviewing pull requests becomes painful. Making changes is risky because you can’t quickly see what else is affected.
The fix is simple: split your code by logical units.
I organise my Terraform files by resource type. A typical folder structure looks like this:
├── main.tf (module calls or primary resources)
├── variables.tf (all variable declarations)
├── outputs.tf (all output declarations)
├── locals.tf (local value definitions)
├── networking.tf (networking resources like vnet, subnets)
├── storage.tf (storage accounts and related config)
├── keyvault.tf (key vault and secrets)
├── vm.tf (virtual machines and related)
├── database.tf (databases)
├── resource_group.tf (resource group)
└── terraform.tf (provider and terraform version constraints)
This structure has massive advantages. When I need to understand how a storage account is configured, I open storage.tf. All the resource logic lives in one place. Variables are centralised, so I know where to look if I need to change defaults.
It’s immediately obvious what resources exist and how they’re organised.
The only time I use main.tf is when I’m calling modules that create multiple resources. In that case, it doesn’t make sense to name the file after a specific resource type, so main.tf serves as the entry point.
This approach scales. Whether you have one module or fifty, the structure remains consistent and easy to navigate.
4. Misunderstanding the sensitive Tag
Here’s a critical security mistake I see constantly: developers think that marking a variable or output as sensitive = true protects the value from being stored in Terraform state.
It doesn’t.
The sensitive flag does one thing: it prevents the value from appearing in the output of terraform plan and terraform apply commands. If you run a plan locally or in a CI/CD pipeline, sensitive outputs are redacted in the console. That’s helpful for keeping secrets off screen.
But the secret is still stored in the Terraform state file – in plain text. If someone has access to your state file, they have the secret. The sensitive tag provides no protection there.
This is why state security is foundational. If you haven’t already, lock down your state file with strong access controls, encryption at rest, and enable state locking to prevent concurrent modifications. But don’t rely on sensitive as your security control.
The Better Approach: Don’t Store Secrets in State
The best way to handle secrets is to not let Terraform create or store them in the first place.
Use secret-less authentication wherever possible. In Azure, this means relying on Managed Identities or Federated Identities (through Entra ID workload identities). These allow your infrastructure to authenticate without needing to manage credentials. Terraform can create the identity and assign permissions, but no secret is generated or stored.
If you genuinely need to store sensitive data, API keys, database passwords, certificates – reference them from Azure Key Vault or HashiCorp Vault instead of having Terraform generate them. Your Terraform code can fetch the secret at runtime without storing it in state:
data "azurerm_key_vault_secret" "db_password" {
name = "db-password"
key_vault_id = azurerm_key_vault.main.id
}
resource "azurerm_mssql_server" "main" {
administrator_login_password = data.azurerm_key_vault_secret.db_password.value
# ... rest of config
}
As of Terraform 1.10, there’s also the ephemeral resource type, which doesn’t get stored in state at all. It only exists during the run. This is useful for situations where you need to generate temporary credentials for deployment purposes, but it comes with caveats, ephemeral resources are recreated on every run, and not all providers support them yet. Check the Terraform registry to see if the resource you need supports the ephemeral type.
I wrote about protecting state and using ephemeral resources in more detail in my post ‘How to Secure Terraform State in Azure’.
5. Misusing Locals
Locals are computed values that are calculated when your code runs. Unlike variables (which accept input from outside), locals are determined internally. The mistake I see is either no locals (duplicating logic throughout), or too many locals (making the code harder to understand).
Use locals when they reduce duplication and improve clarity.
A great use case is constructing a naming convention. If all your resources follow a pattern, compute it once and reuse it:
locals {
name_suffix = "${var.solution_name}-${var.business_group}-${var.environment}-${var.region}"
}
resource "azurerm_storage_account" "main" {
name = "st${replace(local.name_suffix, "-", "")}"
# ... rest of config
}
resource "azurerm_key_vault" "main" {
name = "kv${replace(local.name_suffix, "-", "")}"
# ... rest of config
}
Another excellent use case is lookup tables. If you need to apply different SKU sizes based on the environment, define it once and reference it:
locals {
vm_sku_by_environment = {
dev = "Standard_D2s_v3"
test = "Standard_D4s_v3"
prod = "Standard_D8s_v3"
}
}
resource "azurerm_windows_virtual_machine" "main" {
size = local.vm_sku_by_environment[var.environment]
# ... rest of config
}
This is far cleaner than adding a variable for each environment and adding conditional logic everywhere.
When locals go wrong: Creating too many locals that are only used once. If a local is defined and used in a single resource, it’s not reducing duplication, it’s just adding indirection. Similarly, avoid creating locals that are just renamed variables. If you’re doing local.environment = var.environment, you’re adding noise, not clarity.
The test is simple: does this local reduce duplication and improve readability? If not, remove it.
Summary
These five mistakes are preventable, but they’re also common because they reflect a fundamental misunderstanding of how to build maintainable Terraform code. Infrastructure as Code isn’t just about automating deployments, it’s about creating a codebase that your team can understand, modify, and trust.
Write code that’s reusable and parameterised. Use variables deliberately, with sensible defaults and validation. Organise your files by resource type so anyone can find what they’re looking for. Keep secrets out of state by relying on Managed Identities and vaults. Use locals to eliminate duplication, not to rename things.
Get these right, and you’ll build Terraform projects that scale, that your team can maintain, and that won’t surprise you six months later.