By KP | TZoneLabs | DevOps & Cloud Engineering
A single main.tf with every resource for every environment works for the first few weeks of a project. It stops working the day you need a second environment, and by the time a team is running terraform plan against a 2,000-line file, nobody wants to touch it without asking around first. Splitting resources into modules and moving state off a laptop fixes both problems, but only if you do it in the right order.
This covers when to actually split a module out, how to structure remote state so two environments don’t collide, and the module habits that turn into maintenance headaches six months in.
What a Single-File Setup Looks Like at Its Limit
Most Terraform projects start here, and there’s nothing wrong with it early on:
infra/
main.tf
variables.tf
outputs.tf
terraform.tfvars
The trouble starts when this same file has to describe both staging and production, or when a second engineer needs to change the RDS instance without reading through 40 unrelated resource blocks to find it. Neither problem is really about file length. It’s about one file trying to represent things that don’t share a lifecycle.
Splitting Into Modules by Lifecycle, Not by Service
The instinct is to make one module per AWS service. A cleaner split is by how often each piece changes and who owns it:
infra/
modules/
networking/
main.tf
variables.tf
outputs.tf
eks/
main.tf
variables.tf
outputs.tf
rds/
main.tf
variables.tf
outputs.tf
environments/
staging/
main.tf
backend.tf
production/
main.tf
backend.tf
Networking rarely changes once it’s set up. The EKS cluster changes on a different cadence than the database. Keeping them as separate modules means a change to one doesn’t force a plan against resources that have nothing to do with it:
module "networking" {
source = "../../modules/networking"
cidr_block = "10.0.0.0/16"
environment = "production"
}
module "eks" {
source = "../../modules/eks"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
}
Remote State, and Why Local State Doesn’t Survive a Team
Local state works for one person on one laptop. It breaks the moment two people run apply in the same day, since there’s no shared record of what the infrastructure actually looks like. An S3 backend with DynamoDB locking gives every environment its own state file and stops two applies from racing each other:
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "production/eks/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
The key is what actually separates environments. Staging and production should point at different keys in the same bucket, never the same one with a variable swapped elsewhere, or a mistake in one environment’s variables can quietly apply against the other’s state.
Passing Data Between Modules That Don’t Live in the Same Apply
Module outputs work when everything runs from one root module. They don’t help when the networking module was applied by a different pipeline entirely. For that, read the other module’s state directly:
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "acme-terraform-state"
key = "production/networking/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_eks_cluster" "main" {
vpc_config {
subnet_ids = data.terraform_remote_state.networking.outputs.private_subnet_ids
}
}
This creates a real dependency between the two state files. If the networking module removes an output the EKS module reads, the break shows up as a plan error, not a support ticket three weeks later.
Pinning Module Versions
A module sourced from a Git repo without a ref tracks whatever’s on the default branch at the moment you run init:
# Untracked: pulls whatever main.tf looks like today
module "rds" {
source = "git::https://github.com/acme/tf-modules.git//rds"
}
# Pinned: reproducible across every apply
module "rds" {
source = "git::https://github.com/acme/tf-modules.git//rds?ref=v1.4.0"
}
Without the pin, a change merged into the module repo can alter production infrastructure the next time anyone runs terraform init -upgrade, without a single line changing in the environment’s own code.
Mistakes That Show Up Later, Not Immediately
- Modules with a hardcoded region or account ID. Works fine until the same module needs to run in a second account, and now it needs a rewrite instead of a variable.
- One module per resource. A module wrapping a single S3 bucket adds a layer of indirection without adding any reuse. Group resources that are always created and destroyed together.
- Shared state file across environments with a workspace to “separate” them. Terraform workspaces share the same backend config and are easy to apply against the wrong one by forgetting to switch first. Separate state files per environment remove that failure mode entirely.
- No
prevent_destroyon anything stateful. A database or a data store deserves a lifecycle block that stops an accidentalterraform destroycold, not just a confirmation prompt someone can arrow past.
Key Lessons
-
Split modules by how often they change, not by which AWS service they wrap.
Networking, compute, and data stores rarely change on the same schedule, so they shouldn’t share a plan. -
The state file’s
keyis what actually isolates environments, not a variable.
Two environments pointing at the same state key share the same infrastructure record, whatever the variables say. -
terraform_remote_stateis for modules applied by different pipelines, not a substitute for normal outputs.
Use it to create an explicit, visible dependency between state files that don’t run together. -
An unpinned module source is a silent way to change production.
Pin every module to a tag or commit soinit -upgradecan’t change infrastructure without a code change. -
Stateful resources need
prevent_destroy, not just a habit of being careful.
A lifecycle block stops the mistake before it happens instead of relying on someone reading the plan output closely.
Summary
| Decision | Do | Avoid |
|---|---|---|
| Module boundaries | Split by lifecycle (networking, compute, data) | One module per individual resource |
| Environment isolation | Separate state file per environment | Workspaces sharing one backend |
| Cross-module data | terraform_remote_state for separate pipelines |
Copy-pasting values between repos |
| Module sourcing | Pin to a tag or commit ref | Default branch with no ref |
None of this needs a rewrite to adopt. Pin the module refs first, split the next resource that’s actually causing friction, and let the structure grow from there.
Read Next
- A Killed CI Job Left a Terraform State Lock Behind, and It Blocked Every Pipeline for 40 Minutes
- Debugging AWS IAM Denials Without Guessing at Policy JSON
If you’re running Terraform in production, follow along on LinkedIn for more guides like this one as they’re published.
Tags:
#Terraform #InfrastructureAsCode #DevOps #AWS
#SRE #Modules