Terraform Modules That Don't Break When Your Team Grows

Search for a command to run...

No comments yet. Be the first to comment.
Most Terraform content is written for DevOps and platform teams. This series is for application engineers who build the services and should own the infrastructure that runs them. Each post covers one real decision — module design, state management, secrets, CI/CD, GCP provisioning — with the context that only comes from being close to the application code. No ops background required
At 11pm on a Tuesday, a Cloud Run service stopped accepting traffic. No deployment had happened. No code had changed. The service was running — Cloud Run showed healthy instances — but requests were t
At 11pm on a Tuesday, a Cloud Run service stopped accepting traffic. No deployment had happened. No code had changed. The service was running — Cloud Run showed healthy instances — but requests were t

I became an engineering lead without a transition plan. One week I was an individual contributor shipping features. The next I was responsible for four engineers, client deadlines, sprint planning, co

S01E04 of FastAPI in Production

Last year we moved a production platform off AWS and onto GCP. Full migration — compute, database, storage, CDN, CI/CD, the works. The product was live, clients were active, and we had no meaningful d

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.
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.
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.
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.
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.
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.