Terraform modules are the best way to stop copy/pasting infrastructure code across projects and environments. Instead of rewriting the same VPC, subnets, firewall rules, and settings over and over, you wrap that logic in a module and reuse it with clean inputs/outputs.
This post shows:
- What a module is (in practice)
- How to structure modules in a repo
- How to write a reusable GCP VPC module
- How to call it for dev and prod
- Common module mistakes (and how to avoid them)
What is a Terraform module?
A Terraform module is just a folder containing Terraform files (.tf) that expose:
-
inputs (
variables.tf) -
outputs (
outputs.tf) -
resources (
main.tf, etc.)
You can call a module multiple times with different parameters, like:
module "network_dev" {
source = "../modules/vpc"
project_id = "my-dev-project"
vpc_name = "dev-vpc"
}
Recommended repository structure
Here’s a clean layout that works well for real teams:
terraform/
modules/
vpc/
main.tf
variables.tf
outputs.tf
versions.tf
README.md
envs/
dev/
main.tf
providers.tf
backend.tf
terraform.tfvars
prod/
main.tf
providers.tf
backend.tf
terraform.tfvars
Idea: keep modules generic and environments thin.
Module example: a reusable GCP VPC module
This VPC module will create:
- 1 VPC network
- N subnets (from a list)
- Optional firewall rules (basic example)
modules/vpc/versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = ">= 5.0.0"
}
}
}
modules/vpc/variables.tf
variable "project_id" {
description = "GCP project ID where resources will be created."
type = string
}
variable "vpc_name" {
description = "Name of the VPC."
type = string
}
variable "routing_mode" {
description = "VPC routing mode: REGIONAL or GLOBAL."
type = string
default = "REGIONAL"
validation {
condition = contains(["REGIONAL", "GLOBAL"], var.routing_mode)
error_message = "routing_mode must be REGIONAL or GLOBAL."
}
}
variable "subnets" {
description = <<EOT
List of subnets to create.
Example:
[
{ name="public-us-central1", region="us-central1", cidr="10.10.0.0/24", private_google_access=true },
{ name="private-us-central1", region="us-central1", cidr="10.10.1.0/24", private_google_access=true }
]
EOT
type = list(object({
name = string
region = string
cidr = string
private_google_access = bool
}))
}
variable "firewall_rules" {
description = <<EOT
Optional firewall rules.
Keep it simple: allow protocol/ports from source ranges to target tags.
EOT
type = list(object({
name = string
direction = optional(string, "INGRESS")
priority = optional(number, 1000)
source_ranges = list(string)
target_tags = list(string)
protocol = string
ports = list(string)
}))
default = []
}
modules/vpc/main.tf
resource "google_compute_network" "this" {
project = var.project_id
name = var.vpc_name
auto_create_subnetworks = false
routing_mode = var.routing_mode
}
resource "google_compute_subnetwork" "this" {
for_each = { for s in var.subnets : s.name => s }
project = var.project_id
name = each.value.name
ip_cidr_range = each.value.cidr
region = each.value.region
network = google_compute_network.this.id
private_ip_google_access = each.value.private_google_access
}
resource "google_compute_firewall" "this" {
for_each = { for r in var.firewall_rules : r.name => r }
project = var.project_id
name = each.value.name
network = google_compute_network.this.name
direction = each.value.direction
priority = each.value.priority
target_tags = each.value.target_tags
source_ranges = each.value.source_ranges
allow {
protocol = each.value.protocol
ports = each.value.ports
}
}
modules/vpc/outputs.tf
output "network_id" {
description = "The ID of the VPC network."
value = google_compute_network.this.id
}
output "network_name" {
description = "The name of the VPC network."
value = google_compute_network.this.name
}
output "subnet_ids" {
description = "Subnet IDs keyed by subnet name."
value = { for k, s in google_compute_subnetwork.this : k => s.id }
}
Using the module: dev environment example
envs/dev/main.tf
module "vpc" {
source = "../../modules/vpc"
project_id = var.project_id
vpc_name = "dev-vpc"
subnets = [
{
name = "dev-us-central1-public"
region = "us-central1"
cidr = "10.10.0.0/24"
private_google_access = true
},
{
name = "dev-us-central1-private"
region = "us-central1"
cidr = "10.10.1.0/24"
private_google_access = true
}
]
firewall_rules = [
{
name = "dev-allow-ssh-from-office"
source_ranges = ["203.0.113.10/32"]
target_tags = ["ssh"]
protocol = "tcp"
ports = ["22"]
}
]
}
envs/dev/variables.tf
variable "project_id" {
type = string
description = "Dev project ID"
}
Module tips (this is what saves you later)
1) Keep modules “dumb” and input-driven
Avoid hardcoding:
- regions
- CIDRs
- environment names
- project IDs
2) Prefer composable small modules
Instead of a huge “platform module”, consider:
modules/vpcmodules/cloud_runmodules/cloud_sqlmodules/iam
Then compose them per environment.
3) Be careful with IAM resources
Some IAM resources are authoritative and can remove access unexpectedly.
When in doubt, start with *_iam_member resources and grow from there.
4) Version your modules
If you publish modules (Git tags), you can pin versions:
module "vpc" {
source = "git::https://github.com/YOUR_ORG/YOUR_REPO.git//modules/vpc?ref=v1.0.0"
}
Wrap-up
Terraform modules turn your GCP infrastructure into reusable building blocks:
- less duplication
- safer changes
- consistent environments
Top comments (0)