Terraform Modules That Don't Break When Your Team Grows

Six months into a project, our main.tf was 800 lines long.
Every resource — Cloud Run services, Pub/Sub topics, Cloud SQL, Redis, IAM bindings, secrets — lived in one file. Nobody wanted to touch it. Adding a new Cloud Run service meant scrolling through hundreds of lines to find the right place, hoping you didn't accidentally modify something adjacent. Reviewing a PR meant reading a diff that changed lines 47, 312, and 651 with no obvious relationship between them.
The file worked. It was just impossible to reason about.
Modules are the fix. Not because they're the Terraform-approved way to do things, but because they enforce the same principle that makes application code maintainable: one thing, one place, clear boundaries.
What a Module Is
A module is a directory with Terraform files. That's it. You call it with module block, pass in variables, and get outputs back. It's a function for infrastructure.
# Without modules — everything inline in main.tf
resource "google_cloud_run_v2_service" "producer" { ... }
resource "google_cloud_run_v2_service" "consumer" { ... }
resource "google_pubsub_topic" "commerce_events" { ... }
resource "google_pubsub_subscription" "realtime" { ... }
resource "google_sql_database_instance" "main" { ... }
# ... 750 more lines
# With modules — main.tf becomes a composition
module "pubsub" {
source = "./modules/pubsub"
project_id = var.gcp_project_id
environment = var.environment
}
module "cloud_run" {
source = "./modules/cloud_run"
project_id = var.gcp_project_id
producer_image = var.producer_image
consumer_image = var.consumer_image
consumer_url = module.pubsub.consumer_push_url
}
module "database" {
source = "./modules/database"
project_id = var.gcp_project_id
environment = var.environment
tier = var.db_tier
}
The top-level main.tf is now a readable description of what the system is. The details live in the modules.
Module Structure That Scales
infra/
├── main.tf # module composition only
├── variables.tf # top-level inputs
├── outputs.tf # top-level outputs
├── versions.tf # provider + backend config
├── environments/
│ ├── dev.tfvars
│ └── prod.tfvars
└── modules/
├── pubsub/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── cloud_run/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── database/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── redis/
├── main.tf
├── variables.tf
└── outputs.tf
Every module has three files. main.tf declares resources. variables.tf declares inputs. outputs.tf declares what the module exposes to the outside. Nothing else.
The rule: if a resource belongs to a specific service or concern, it lives in that module. If you're not sure which module a resource belongs to, that's a signal the module boundaries need rethinking.
Writing a Module That's Actually Reusable
A module that works only for one exact configuration isn't a module — it's just a file split. The variables interface is what makes a module reusable.
# modules/cloud_run/variables.tf
variable "project_id" {
type = string
description = "GCP project ID"
}
variable "region" {
type = string
default = "us-central1"
}
variable "producer_image" {
type = string
description = "Docker image URI for the producer service"
}
variable "min_instances" {
type = number
default = 0
description = "Minimum Cloud Run instances. Set to 1+ in prod to avoid cold starts."
}
variable "max_instances" {
type = number
default = 10
}
variable "service_account_email" {
type = string
}
# modules/cloud_run/main.tf
resource "google_cloud_run_v2_service" "producer" {
name = "pulsecart-producer"
location = var.region
template {
scaling {
min_instance_count = var.min_instances
max_instance_count = var.max_instances
}
containers {
image = var.producer_image
}
service_account = var.service_account_email
}
}
# modules/cloud_run/outputs.tf
output "producer_url" {
value = google_cloud_run_v2_service.producer.uri
description = "URL of the deployed producer service"
}
# environments/prod.tfvars
min_instances = 1
max_instances = 20
db_tier = "db-custom-4-15360"
# environments/dev.tfvars
min_instances = 0
max_instances = 5
db_tier = "db-g1-small"
The same module, two environments, zero duplication. Changing the prod scaling config is one line in prod.tfvars — not a search through 800 lines of main.tf.
Module Outputs and Cross-Module Dependencies
Modules need to talk to each other. The Cloud Run module needs the Pub/Sub push endpoint. The Pub/Sub module needs the Cloud Run service URL for the push subscription. Outputs wire them together cleanly:
# main.tf — passing outputs between modules
module "cloud_run" {
source = "./modules/cloud_run"
# Pass the consumer URL to Pub/Sub so it knows where to push
consumer_url = module.cloud_run.consumer_url # ← circular?
}
module "pubsub" {
source = "./modules/pubsub"
consumer_push_url = module.cloud_run.consumer_url # ← resolved at plan time
}
Terraform resolves inter-module dependencies automatically at plan time — it builds a dependency graph and applies resources in the right order. You don't need to manage this manually.
Watch for genuine circular dependencies (module A needs an output from module B, which needs an output from module A). These require restructuring — usually by extracting the shared resource into a third module that both depend on.
The One Rule
Each module owns exactly one concern. Pub/Sub owns topics and subscriptions. Cloud Run owns services and revisions. Database owns the Cloud SQL instance and its config. When a module starts owning two unrelated things, split it.
This rule is what keeps modules reviewable. A PR that touches only modules/pubsub/ tells a reviewer exactly what changed and why. A PR that touches main.tf line 47, 312, and 651 tells them nothing.





