Things I Wish I Knew Before Using Terraform
Terraform is incredibly powerful. No doubt about it. But if you're like me, you probably started using it because you wanted to automate infrastructure, not spend your nights debugging why an aws_subnet refuses to associate with a VPC you swear exists.
This article is a collection of things I wish someone had told me before I dove in. These aren't deep technical gotchas. They're the small architectural and workflow decisions that compound over time and turn a clean setup into a pile of spaghetti. Learn from my pain.
Open Source Modules Are a Beginner's Trap
When I first started with Terraform, I leaned heavily on open-source modules. Why not? They promised quick infrastructure setup and "battle-tested" code. The problem? Most of them are bloated with conditionals and edge cases to support every scenario except yours.
For example, you might pull a VPC module to create one VPC and three subnets, then discover it exposes dozens of toggles for NAT strategies, flow logs, IPv6, transit gateways, and routing edge cases you don't need yet.
Once things break (and they will), you'll find yourself neck-deep in someone else's codebase, trying to reverse-engineer why a random flag buried three levels deep changed your RDS networking behavior.
If you're just starting, write your own focused modules. You'll understand your infrastructure better and avoid black-box debugging hell.
Put Only One Resource in a File
Grouping resources in files like main.tf, compute.tf, or storage.tf seems
tidy at first, until your app grows. Then you're stuck figuring out where to
put a resource that doesn't cleanly fit into any group.
By resource, I mean one Terraform resource block per file, such as
aws_db_instance.app, aws_db_subnet_group.app, or
aws_security_group_rule.app_to_db.
Even worse, merging changes across branches becomes painful when multiple
engineers are modifying the same files. The fix? Put each resource in its own
.tf file. It's not overkill. It's a lifesaver. This approach reduces merge
conflicts but may create more files; weigh it against your team's preferences
for logical grouping. Want to organize? Use folders by provider or service
(aws/vpc/, google/cloudrun/), but keep the individual resources separate.
It'll keep your diffs clean, your Git merges smooth, and your sanity intact.
Use locals.tf as a Sanity Buffer
Don't wire outputs from one resource directly into another. It seems efficient, until you're dealing with deeply nested chains of unreadable references.
Instead, use a dedicated locals.tf file to stage data. Transform outputs,
assign clean names, and then feed those into other resources. It's your
translation layer, a place to reshape what Terraform gives you into
something you actually want to work with.
It also prevents duplicating the same expressions across multiple resource files, which matters even more when you follow the one-resource-per-file approach.
locals.tf is only a naming convention; Terraform reads all .tf files in a
directory, and what matters is the locals block itself.
Example:
# locals.tf
locals {
app_name = "billing-api"
db_identifier = "${var.environment}-${local.app_name}"
private_subnet_ids = [for subnet in aws_subnet.private : subnet.id]
}Then consume those values from resource files:
# aws_db_instance.app.tf
resource "aws_db_instance" "app" {
identifier = local.db_identifier
# ...
}A little upfront investment in locals saves a lot of downstream pain.
Keep Providers at the Root. Always
Yes, Terraform lets you declare providers inside modules. Don't do it. Just because you can doesn't mean you should.
If you accidentally use different versions of the same provider across modules, you'll start getting subtle differences in outputs or behavior. Good luck debugging that.
In Terraform, a module is just a directory of .tf files.
Instead, define all providers in the root module (commonly in a providers.tf
file, which is another naming convention) and explicitly pass them into your
submodules. It's a bit more boilerplate, but you maintain full control,
consistency and avoid provider version drift.
# providers.tf (root module)
provider "aws" {
region = var.aws_region
}
module "network" {
source = "./modules/network"
providers = { aws = aws }
}Use Descriptive Names. Not "Primary" or "Secondary"
Early on, I had resources named primary_vpc, main_instance, secondary_db.
That felt fine, until I started deploying to multiple regions and
environments.
What does primary mean in us-west-2 vs us-east-1? What's main in
production vs staging?
Be specific. If you're creating a VPC in us-east-1, name it us_east_1_vpc.
If it's for staging, call it staging_us_east_1_vpc. It might seem verbose,
but it makes your logs readable, your state understandable, and your team
grateful.
Separate Environment Variables into Dedicated Files
locals.tf is not the place for environment variables. I made that mistake,
and it quickly turned into an unreadable dump of hardcoded values, if-else
logic, and untraceable overrides.
Keep the split clear:
locals.tfis for computed values reused across resources (naming, merged tags, transformed outputs).envs.tf(orstaging_env.tf/production_env.tf) is for simple hardcoded environment config.
For example, locals.tf can hold computed values:
locals {
service_name = "billing-api"
tags = {
service = local.service_name
managed_by = "terraform"
}
}And staging_env.tf can hold environment-specific values that even a
non-Terraform engineer can safely update:
locals {
env_vars = {
LOG_LEVEL = "debug"
FEATURE_FLAG_X = "true"
PAYMENT_PROVIDER = "sandbox"
}
}This keeps your environment variables isolated, easy to review, and free from the rest of your Terraform logic. Then reference them in your resources as needed. And trust me, the number of environment-specific variables will always grow.
Use for_each with Meaningful Keys. Avoid count
Using count to create multiple resources works, until it doesn't. Terraform
labels them with indices like [0], [1], and when something breaks, you're
left guessing which one is which.
Want readable plans and debuggable logs? Use for_each with a map instead.
For example:
variable "resource_by_name" {
type = map(object({
size = string
tier = string
}))
}
resource "awesome_resource" "example" {
for_each = var.resource_by_name
name = each.key
size = each.value.size
tier = each.value.tier
}Now your logs will show:
resource.awesome_resource["resource_a"]
resource.awesome_resource["resource_b"]
resource.awesome_resource["resource_c"]Clear. Descriptive. Easy to trace. It's a no-brainer. Just ensure keys are unique and stable to avoid resource recreation during updates.
Resources
What Terraform pitfalls have you encountered? Email us at [email protected].