Terraform modules let you package infrastructure patterns into reusable, versioned components. Instead of copying configuration between projects, you define a module once and instantiate it with different parameters. This guide covers building production-quality Terraform modules for VPS provisioning and common infrastructure patterns.
Module Structure
# Standard module directory layout
modules/
├── vps-server/
│ ├── main.tf # Core resources
│ ├── variables.tf # Input variables
│ ├── outputs.tf # Output values
│ ├── versions.tf # Provider requirements
│ ├── data.tf # Data sources
│ ├── locals.tf # Local values
│ └── README.md # Documentation
├── load-balancer/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── monitoring/
├── main.tf
├── variables.tf
└── outputs.tf
VPS Server Module
# modules/vps-server/variables.tf
variable "name" {
description = "Server hostname"
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,62}$", var.name))
error_message = "Name must be lowercase alphanumeric with hyphens, 2-63 chars."
}
}
variable "plan" {
description = "Server plan (size)"
type = string
default = "cx22"
validation {
condition = contains(["cx22", "cx32", "cx42", "cx52"], var.plan)
error_message = "Must be a valid plan: cx22, cx32, cx42, cx52."
}
}
variable "image" {
description = "OS image"
type = string
default = "ubuntu-24.04"
}
variable "location" {
description = "Datacenter location"
type = string
default = "nbg1"
}
variable "ssh_keys" {
description = "List of SSH key IDs"
type = list(string)
}
variable "firewall_rules" {
description = "Firewall rules"
type = list(object({
direction = string
protocol = string
port = string
source_ips = list(string)
}))
default = [
{
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["0.0.0.0/0", "::/0"]
},
{
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["0.0.0.0/0", "::/0"]
},
{
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
]
}
variable "backups" {
description = "Enable automated backups"
type = bool
default = true
}
variable "labels" {
description = "Labels to apply"
type = map(string)
default = {}
}
variable "user_data" {
description = "Cloud-init user data"
type = string
default = ""
}
# modules/vps-server/main.tf
terraform {
required_version = ">= 1.7.0"
}
locals {
default_labels = {
managed_by = "terraform"
module = "vps-server"
}
labels = merge(local.default_labels, var.labels)
}
resource "hcloud_server" "this" {
name = var.name
server_type = var.plan
image = var.image
location = var.location
ssh_keys = var.ssh_keys
backups = var.backups
labels = local.labels
user_data = var.user_data
public_net {
ipv4_enabled = true
ipv6_enabled = true
}
lifecycle {
ignore_changes = [user_data, ssh_keys]
}
}
resource "hcloud_firewall" "this" {
name = "${var.name}-fw"
labels = local.labels
dynamic "rule" {
for_each = var.firewall_rules
content {
direction = rule.value.direction
protocol = rule.value.protocol
port = rule.value.port
source_ips = rule.value.source_ips
}
}
}
resource "hcloud_firewall_attachment" "this" {
firewall_id = hcloud_firewall.this.id
server_ids = [hcloud_server.this.id]
}
resource "hcloud_rdns" "ipv4" {
server_id = hcloud_server.this.id
ip_address = hcloud_server.this.ipv4_address
dns_ptr = "${var.name}.example.com"
}
# modules/vps-server/outputs.tf
output "id" {
description = "Server ID"
value = hcloud_server.this.id
}
output "ipv4_address" {
description = "Public IPv4 address"
value = hcloud_server.this.ipv4_address
}
output "ipv6_address" {
description = "Public IPv6 address"
value = hcloud_server.this.ipv6_address
}
output "status" {
description = "Server status"
value = hcloud_server.this.status
}
Using Modules
# environments/production/main.tf
module "web_server_1" {
source = "../../modules/vps-server"
name = "web-prod-1"
plan = "cx32"
image = "ubuntu-24.04"
location = "nbg1"
ssh_keys = [hcloud_ssh_key.deploy.id]
backups = true
labels = {
environment = "production"
role = "webserver"
}
firewall_rules = [
{
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["203.0.113.0/24"] # Office IP only
},
{
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["0.0.0.0/0", "::/0"]
},
{
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
]
}
module "db_server" {
source = "../../modules/vps-server"
name = "db-prod-1"
plan = "cx42"
image = "ubuntu-24.04"
location = "nbg1"
ssh_keys = [hcloud_ssh_key.deploy.id]
labels = {
environment = "production"
role = "database"
}
firewall_rules = [
{
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["203.0.113.0/24"]
},
{
direction = "in"
protocol = "tcp"
port = "5432"
source_ips = ["${module.web_server_1.ipv4_address}/32"]
}
]
}
# Use outputs
output "web_server_ip" {
value = module.web_server_1.ipv4_address
}
output "db_server_ip" {
value = module.db_server.ipv4_address
}
Module Versioning
# Use versioned modules from a Git repository
module "web_server" {
source = "git::https://github.com/myorg/terraform-modules.git//vps-server?ref=v1.2.0"
# ...
}
# Or from a Terraform registry
module "web_server" {
source = "myorg/vps-server/hcloud"
version = "~> 1.2"
# ...
}
# Or from a local path (for monorepo development)
module "web_server" {
source = "../../modules/vps-server"
# ...
}
Testing Modules
# Use terraform test (built-in since Terraform 1.6)
# tests/vps_server.tftest.hcl
run "create_server" {
command = plan
variables {
name = "test-server"
plan = "cx22"
ssh_keys = ["12345"]
}
assert {
condition = hcloud_server.this.name == "test-server"
error_message = "Server name mismatch"
}
assert {
condition = hcloud_server.this.server_type == "cx22"
error_message = "Server type mismatch"
}
assert {
condition = hcloud_server.this.backups == true
error_message = "Backups should be enabled by default"
}
}
# Run tests
terraform test
Best Practices
- Use semantic versioning for module releases and pin versions in consumers
- Add input validation to catch errors early before resource creation
- Document all variables and outputs — use
terraform-docsto auto-generate docs - Keep modules focused: One module = one logical infrastructure component
- Use
lifecycleblocks to prevent accidental destruction of critical resources - Write tests using
terraform testto validate module behavior