Intermediate

Terraform: Modules

Module fundamentals, usage, development & design, and publishing and versioning modules.

Table of Contents


1. Module Fundamentals

1.1 Terraform Module Definition

A Terraform module is simply a directory containing Terraform files. That’s it. This minimalist definition has an important implication: the configuration in which you run Terraform commands is itself a module — the root module.

The module concept is at the heart of the DRY (Don’t Repeat Yourself) principle applied to Infrastructure as Code. Rather than rewriting the same resources in every project, you encapsulate repetitive patterns into reusable modules.

1.2 Root module vs Child modules

flowchart TD
    RM["🟦 Root Module\n(working directory)"]
    CM1["🟩 Child Module\nVPC"]
    CM2["🟩 Child Module\nweb-front-end"]
    CM3["🟧 Child Module\nDatabase"]
    GCM["🟨 Grand-child Module\nIAM Security"]

    RM -->|"module 'vpc'"| CM1
    RM -->|"module 'web_front_end'"| CM2
    RM -->|"module 'database'"| CM3
    CM2 -->|"module 'iam_security'"| GCM
CharacteristicRoot ModuleChild Module
DefinitionTarget directory of Terraform commandsDirectory invoked by a parent
Commandsterraform init, plan, applyNo direct commands
ParentNoneRoot module or another child module
Can invokeChild modulesOther child modules
Receives information fromUser input variables / .tfvarsIts parent module via module block arguments

1.3 Common module types

There are three main categories of modules:

TypeDescriptionExample
Resource modulesDeploy a set of resources following a common infrastructure patternAWS VPC module, EKS module, web front-end module
Data-only modulesUse data sources to query and transform informationApproved AMI catalog module
Function modulesApply a data transformation on inputs, without resourcesCIDR calculation, tag transformation

Resource modules are the most common. They allow you to:

  • Encapsulate best practices in a reusable abstraction
  • Simplify the interface for the module consumer
  • Standardize deployments across an organization

1.4 Terraform module structure

flowchart LR
    subgraph MODULE["📁 modules/web-front-end/"]
        V["variables.tf\n(inputs)"]
        M["main.tf\n(resources)"]
        O["outputs.tf\n(outputs)"]
        T["versions.tf\n(required_providers)"]
        R["README.md"]
    end

    subgraph ROOT["📁 root module/"]
        RV["variables.tf"]
        RM2["main-ec2.tf\nmodule 'web_front_end'"]
        RO["outputs.tf"]
        RP["providers.tf"]
        RTF["terraform.tf"]
        RTFV["terraform.tfvars"]
    end

    RM2 -->|"source = ./modules/web-front-end"| MODULE
    RV -->|"inputs"| RM2
    MODULE -->|"module.web_front_end.output_name"| RO

2. Using Terraform Modules

2.1 The module block syntax

The module block is the only new syntactic element for using modules. It takes a label (name) and the mandatory source argument.

module "vpc" {
  # source is the ONLY REQUIRED argument
  # its value must be a literal (not a variable, not an expression)
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  # Optional meta-arguments
  count      = 2
  for_each   = toset(["prod", "staging"])
  depends_on = [aws_iam_role.example]

  providers = {
    aws = aws.us-east-1
  }

  # Arguments = input variables of the child module
  name = "my-vpc"
  cidr = "10.0.0.0/16"
}

Important: The value of source must be a literal value. You cannot use var.module_source or a Terraform expression.

2.2 Module inputs and outputs

Modules behave like functions: they have well-defined inputs (input variables), well-defined outputs (output values), and their internal scope is isolated.

Module scoping

flowchart LR
    subgraph PARENT["Root Module (parent)"]
        PV["var.vpc_cidr\nvar.prefix\nlocal.tags\ndata.aws_az"]
    end

    subgraph CHILD["Child Module"]
        CI["variable 'vpc_cidr'\nvariable 'prefix'"]
        CR["internal resources\ninternal locals\ninternal data sources"]
        CO["output 'vpc_id'\noutput 'subnet_ids'"]
    end

    PV -->|"module block arguments\n(only way to send info)"| CI
    CO -->|"module.child_name.vpc_id\n(only way to retrieve info)"| PARENT
    CR -.->|"NOT accessible\nfrom parent"| PARENT
    PARENT -.->|"NOT accessible\nfrom child\n(except providers)"| CR

Defining input variables in a child module

# modules/web-front-end/variables.tf

variable "app_port" {
  type        = number
  description = "Port the application listens on"
  default     = 80
}

variable "autoscale_group_min_max" {
  type = object({
    min = number
    max = number
  })
  description = "The minimum and maximum size for the autoscale group."
}

variable "autoscale_group_size" {
  type        = number
  description = "Default size of autoscale group."
}

variable "environment" {
  type        = string
  description = "(Required) Environment of all resources"
}

variable "instance_type" {
  type        = string
  description = "Instance type for Autoscale group"
  default     = "t3.micro"
}

variable "launch_template_ami" {
  type        = string
  description = "AMI ID to use for the launch template"
}

variable "prefix" {
  type        = string
  description = "(Required) Prefix to use for all resources in this module."
}

variable "public_subnet_ids" {
  type        = list(string)
  description = "List of public subnet IDs for the autoscale group and NLB."
}

variable "user_data_contents" {
  type        = string
  description = "User data script contents for the launch template."
}

variable "vpc_id" {
  type        = string
  description = "VPC ID where resources will be deployed."
}

Defining outputs in a child module

# modules/web-front-end/outputs.tf

output "autoscaling_group_name" {
  description = "The name of the autoscaling group"
  value       = aws_autoscaling_group.front_end.name
}

output "lb_public_dns" {
  description = "The public DNS name of the load balancer"
  value       = aws_lb.front_end.dns_name
}

Referencing a module output from the parent

# In the root module
resource "aws_autoscaling_policy" "scale_up" {
  autoscaling_group_name = module.web_front_end.autoscaling_group_name
}

output "public_nlb_dns" {
  value = module.web_front_end.lb_public_dns
}

The syntax is: module.<module_name>.<output_name>

2.3 Module providers

Providers are passed from the root module to child modules. There are two mechanisms:

Implicit inheritance

By default, a child module inherits the default (non-aliased) provider instance from the root module. No additional configuration is needed.

Explicit inheritance with the providers argument

# Root module - providers.tf
provider "aws" {
  region = "us-west-2"
}

provider "aws" {
  alias  = "dr"
  region = "us-east-1"
}

provider "aws" {
  alias  = "security"
  region = "eu-west-1"
}

# Root module - main.tf
module "network_dr" {
  source = "./modules/network"

  providers = {
    aws = aws.dr  # The child module will use the "dr" instance
  }
}

module "security_baseline" {
  source = "./modules/security"

  providers = {
    aws          = aws.security
    aws.logging  = aws.dr
  }
}

Multiple aliases in a child module

To use multiple aliases in a child module, declare them in the required_providers block:

# modules/multi-region/versions.tf
terraform {
  required_providers {
    aws = {
      source               = "hashicorp/aws"
      version              = ">= 6.0"
      configuration_aliases = [aws.logging]
    }
  }
}

2.4 Module sources

flowchart TD
    S["Module Source"]
    S --> LOCAL["📁 Local\n./modules/web-front-end\n../shared/modules/vpc"]
    S --> REG["🌐 Terraform Registry\nhashicorp/consul/aws\nregistry.example.com/org/module/aws"]
    S --> GH["🐙 GitHub\ngithub.com/org/repo\ngithub.com/org/repo//subdir"]
    S --> GIT["🔗 Generic Git\ngit::https://example.com/repo.git\ngit::ssh://...?ref=v1.0.0"]
    S --> BB["Bitbucket\nbitbucket.org/org/repo"]
    S --> URL["🗜️ Archive URL\nhttps://example.com/module.zip\nhttps://example.com/module.tar.gz"]
    S --> S3["☁️ S3 Bucket\ns3::https://bucket.region.amazonaws.com/module.zip"]
    S --> GCS["☁️ GCS Bucket\ngcs::https://storage.googleapis.com/bucket/module.zip"]

    style REG fill:#4CAF50,color:#fff
    style LOCAL fill:#2196F3,color:#fff
SourceSyntaxVersion constraint
Local./modules/name or ../shared❌ Not supported
Public Registryorg/module/providerversion = "~> 5.0"
Private Registryhostname/org/module/providerversion = "~> 5.0"
GitHubgithub.com/org/repo❌ Use ?ref=v1.0.0
Gitgit::https://...?ref=tag❌ Use ?ref=
Archive URLhttps://example.com/module.zip❌ Not supported
S3s3::https://...❌ Not supported

Tip: In practice, the vast majority of teams use either the local filesystem (organization’s private modules) or the Terraform Registry (public modules or enterprise private registries).

2.5 Adding a module from the Terraform Registry

sequenceDiagram
    participant Dev as Developer
    participant TF as Terraform CLI
    participant REG as Terraform Registry
    participant AWS as AWS Provider

    Dev->>TF: terraform init
    TF->>REG: Resolve source "terraform-aws-modules/vpc/aws"
    REG-->>TF: Return module archive v5.1.4
    TF->>TF: Extract module into .terraform/modules/
    TF->>AWS: Initialize hashicorp/aws provider
    Dev->>TF: terraform plan
    TF->>TF: Evaluate module inputs
    TF->>AWS: Create resource plan
    AWS-->>TF: Plan generated
    TF-->>Dev: Display plan

Concrete example: Replace manual VPC resources with the official AWS VPC module.

Before (manual resources in main-network.tf):

# main-network.tf - BEFORE (without module)

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_address_range
  enable_dns_hostnames = true
  tags = {
    Name = "${var.prefix}-vpc"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

resource "aws_subnet" "public_subnets" {
  count                   = length(var.vpc_public_subnet_ranges)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.vpc_public_subnet_ranges[count.index]
  availability_zone       = data.aws_availability_zones.available.names[count.index % length(data.aws_availability_zones.available.names)]
  map_public_ip_on_launch = true
  tags = {
    Name = "${var.prefix}-public-subnet-${count.index + 1}"
  }
}

resource "aws_route_table" "default" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
}

resource "aws_route_table_association" "public_subnets" {
  count          = length(var.vpc_public_subnet_ranges)
  subnet_id      = aws_subnet.public_subnets[count.index].id
  route_table_id = aws_route_table.default.id
}

After (with the Terraform Registry module):

# main-network.tf - AFTER (with module)

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name                 = "${var.prefix}-vpc"
  cidr                 = var.vpc_address_range
  azs                  = data.aws_availability_zones.available.names
  public_subnets       = var.vpc_public_subnet_ranges
  enable_dns_hostnames = true
  map_public_ip_on_launch = true
}

# Reference module outputs
# module.vpc.vpc_id
# module.vpc.public_subnets

3. Module Development and Design

3.1 When to write a module?

HashiCorp has formalized a decision tree to guide this thinking:

flowchart TD
    Q1{"Is it a recurring\narchitectural pattern\n(e.g. VPC + subnets + IGW)?"}
    Q2{"Does the service require\na lot of configuration\n(e.g. AKS, EKS)?"}
    Q3{"Is it a custom set\nof multiple\nresource types?"}
    YES["✅ Write a module\n(resource module)"]
    NO["❌ No module needed\nPut the resource\ndirectly in the root"]

    Q1 -->|Yes| YES
    Q1 -->|No| Q2
    Q2 -->|Yes| YES
    Q2 -->|No| Q3
    Q3 -->|Yes| YES
    Q3 -->|No| NO

Golden rule: A module should provide abstraction or describe an architectural concept. It should not be simply a wrapper around a single resource type.

3.2 Planning a new module

Before writing a single line of code, answer three questions:

  1. What resources are part of the module?
  2. What inputs are expected from the parent module?
  3. What outputs will be returned?

3.3 Design principles

Module composition

flowchart TB
    subgraph ROOT["Root Module (orchestrator)"]
        RM["main-ec2.tf\nmain-network.tf\noutputs.tf"]
    end

    subgraph MVPC["Module vpc"]
        VPC["aws_vpc\naws_subnet\naws_internet_gateway\naws_route_table"]
    end

    subgraph MWF["Module web-front-end"]
        WF["aws_security_group\naws_launch_template\naws_autoscaling_group\naws_lb"]
    end

    subgraph MDB["Module database (future)"]
        DB["aws_db_instance\naws_db_subnet_group"]
    end

    ROOT -->|"module 'vpc'\nvpc_cidr, azs"| MVPC
    ROOT -->|"module 'web_front_end'\nvpc_id ← module.vpc.vpc_id\nsubnets ← module.vpc.public_subnets"| MWF
    ROOT -->|"module 'database'\nvpc_id ← module.vpc.vpc_id"| MDB

    style ROOT fill:#1565C0,color:#fff
    style MVPC fill:#2E7D32,color:#fff
    style MWF fill:#6A1B9A,color:#fff
    style MDB fill:#4E342E,color:#fff

Module composition: the root module is the composer that orchestrates modules and circulates information between them.

Dependency inversion

The module should not have dependencies on resources outside of it. The information it needs is injected via its input variables.

# ❌ BAD: tight coupling — the module directly accesses the parent's VPC
resource "aws_autoscaling_group" "front_end" {
  vpc_zone_identifier = aws_subnet.public_subnets[*].id  # parent reference!
}

# ✅ GOOD: dependency injection via input variable
variable "public_subnet_ids" {
  type        = list(string)
  description = "List of public subnet IDs for the autoscale group and NLB."
}

resource "aws_autoscaling_group" "front_end" {
  vpc_zone_identifier = var.public_subnet_ids  # injected dependency
}

Optional creation (conditional resources)

variable "create_nlb" {
  type        = bool
  description = "Whether to create a Network Load Balancer"
  default     = true
}

resource "aws_lb" "front_end" {
  count = var.create_nlb ? 1 : 0
  # ...
}
modules/
└── web-front-end/           ← module directory
    ├── main.tf              ← main resources
    ├── variables.tf         ← input variables
    ├── outputs.tf           ← output values
    ├── versions.tf          ← required_providers (or terraform.tf)
    ├── README.md            ← documentation (terraform-docs)
    ├── examples/            ← usage examples (recommended)
    │   ├── basic/
    │   └── complete/
    └── tests/               ← Terraform tests (recommended)

Recommended tool: terraform-docs automatically generates markdown documentation for variables and outputs.

3.5 Creating the web-front-end module

versions.tf — module required providers

# modules/web-front-end/versions.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 6.14.0"
    }
  }
}

variables.tf — module inputs

# modules/web-front-end/variables.tf

variable "app_port" {
  type        = number
  description = "Port the application listens on"
  default     = 80
}

variable "autoscale_group_min_max" {
  type = object({
    min = number
    max = number
  })
  description = "The minimum and maximum size for the autoscale group."
}

variable "autoscale_group_size" {
  type        = number
  description = "Default size of autoscale group."
}

variable "environment" {
  type        = string
  description = "(Required) Environment of all resources"
}

variable "instance_type" {
  type        = string
  description = "Instance type for Autoscale group"
  default     = "t3.micro"
}

variable "launch_template_ami" {
  type        = string
  description = "AMI ID to use for the launch template"
}

variable "prefix" {
  type        = string
  description = "(Required) Prefix to use for all resources in this module."
}

variable "public_subnet_ids" {
  type        = list(string)
  description = "List of public subnet IDs for the autoscale group and NLB."
}

variable "user_data_contents" {
  type        = string
  description = "User data script contents for the launch template."
}

variable "vpc_id" {
  type        = string
  description = "VPC ID where resources will be deployed."
}

main.tf — module resources

# modules/web-front-end/main.tf

# Security Groups
resource "aws_security_group" "nlb_sg" {
  name_prefix = "${var.prefix}-nlb-sg-"
  vpc_id      = var.vpc_id

  ingress {
    description = "Allow HTTP traffic from anywhere"
    from_port   = var.app_port
    to_port     = var.app_port
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "Allow all outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "${var.prefix}-${var.environment}-nlb-sg"
    Environment = var.environment
  }
}

resource "aws_security_group" "ec2_sg" {
  name_prefix = "${var.prefix}-ec2-sg-"
  vpc_id      = var.vpc_id

  ingress {
    description     = "Allow HTTP traffic from NLB"
    from_port       = var.app_port
    to_port         = var.app_port
    protocol        = "tcp"
    security_groups = [aws_security_group.nlb_sg.id]
  }

  egress {
    description = "Allow all outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "${var.prefix}-${var.environment}-ec2-sg"
    Environment = var.environment
  }
}

# Launch Template & Autoscaling Group
resource "aws_launch_template" "front_end" {
  name_prefix   = "${var.prefix}-web-"
  image_id      = var.launch_template_ami
  instance_type = var.instance_type

  user_data     = var.user_data_contents

  vpc_security_group_ids = [aws_security_group.ec2_sg.id]

  tag_specifications {
    resource_type = "instance"
    tags = {
      Name        = "${var.prefix}-${var.environment}-ec2-instance"
      Environment = var.environment
    }
  }
}

resource "aws_autoscaling_group" "front_end" {
  vpc_zone_identifier = var.public_subnet_ids
  desired_capacity    = var.autoscale_group_size
  max_size            = var.autoscale_group_min_max.max
  min_size            = var.autoscale_group_min_max.min

  launch_template {
    id      = aws_launch_template.front_end.id
    version = "$Latest"
  }

  health_check_type = "ELB"

  tag {
    key                 = "Name"
    value               = "${var.prefix}-${var.environment}-asg"
    propagate_at_launch = false
  }

  tag {
    key                 = "Environment"
    value               = var.environment
    propagate_at_launch = true
  }
}

# Network Load Balancer
resource "aws_lb" "front_end" {
  name               = "${var.prefix}-${var.environment}-nlb"
  internal           = false
  load_balancer_type = "network"
  subnets            = var.public_subnet_ids

  tags = {
    Name        = "${var.prefix}-${var.environment}-nlb"
    Environment = var.environment
  }
}

resource "aws_lb_target_group" "front_end" {
  name     = "${var.prefix}-${var.environment}-tg"
  port     = var.app_port
  protocol = "TCP"
  vpc_id   = var.vpc_id
}

resource "aws_autoscaling_attachment" "front_end" {
  autoscaling_group_name = aws_autoscaling_group.front_end.id
  lb_target_group_arn    = aws_lb_target_group.front_end.arn
}

outputs.tf — module outputs

# modules/web-front-end/outputs.tf

output "autoscaling_group_name" {
  description = "The name of the autoscaling group"
  value       = aws_autoscaling_group.front_end.name
}

output "lb_public_dns" {
  description = "The public DNS name of the load balancer"
  value       = aws_lb.front_end.dns_name
}

3.6 Refactoring the root module

Once the module is created, the root module is refactored to invoke it. The root module becomes an orchestrator that composes modules together.

# main-ec2.tf (root module)

# Data source for the AMI — stays in the root module (dependency inversion)
data "aws_ssm_parameter" "amzn2_linux" {
  name = "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
}

# Invoke the web-front-end module
module "web_front_end" {
  source = "./modules/web-front-end"

  # Variables directly from the root module
  app_port                = var.app_port
  autoscale_group_size    = var.autoscale_group_size
  autoscale_group_min_max = var.autoscale_group_min_max
  environment             = var.environment
  instance_type           = var.instance_type
  prefix                  = var.prefix

  # Dependency injection: values from other modules
  public_subnet_ids   = module.vpc.public_subnets  # from the VPC module
  vpc_id              = module.vpc.vpc_id           # from the VPC module

  # Value from a local data source
  launch_template_ami = data.aws_ssm_parameter.amzn2_linux.value

  # user_data rendered in root module (dependency inversion)
  user_data_contents = base64encode(templatefile("./templates/startup_script.tpl", {
    environment = var.environment
  }))
}

# Autoscaling Policies — stay in the root module (out of scope of the module)
resource "aws_autoscaling_policy" "scale_up" {
  name                   = "${var.prefix}-${var.environment}-scale-up"
  scaling_adjustment     = 1
  adjustment_type        = "ChangeInCapacity"
  cooldown               = 300
  autoscaling_group_name = module.web_front_end.autoscaling_group_name
  policy_type            = "SimpleScaling"
}

resource "aws_autoscaling_policy" "scale_down" {
  name                   = "${var.prefix}-${var.environment}-scale-down"
  scaling_adjustment     = -1
  adjustment_type        = "ChangeInCapacity"
  cooldown               = 300
  autoscaling_group_name = module.web_front_end.autoscaling_group_name
  policy_type            = "SimpleScaling"
}
# outputs.tf (root module)

output "public_nlb_dns" {
  description = "Public DNS of the Network Load Balancer"
  value       = module.web_front_end.lb_public_dns
}

After adding a new local module, you must rerun terraform init so Terraform resolves the new modules before running terraform plan.


4. Module Publishing and Versioning

4.1 Semantic versioning

Modules follow the Semantic Versioning (semver) format: MAJOR.MINOR.PATCH

flowchart LR
    V["Version\ne.g. 2.3.1"]
    V --> MAJ["MAJOR: 2\n\nBreaking changes\nForces code update"]
    V --> MIN["MINOR: 3\n\nNew optional features\nBackward compatible"]
    V --> PAT["PATCH: 1\n\nBug fixes\nNo new features\nBackward compatible"]

    style MAJ fill:#C62828,color:#fff
    style MIN fill:#1565C0,color:#fff
    style PAT fill:#2E7D32,color:#fff

4.2 Version constraints

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"

  # Exact version
  version = "5.1.4"

  # Comparison operators
  version = ">= 5.0.0"
  version = "< 6.0.0"
  version = ">= 5.0.0, < 6.0.0"

  # Pessimistic constraint operator (~>) — MOST COMMON
  # Only allows the rightmost digit to increment
  version = "~> 5.1"    # equivalent to >= 5.1, < 6.0
  version = "~> 5.1.4"  # equivalent to >= 5.1.4, < 5.2.0

  # NOT operator (rare)
  version = "!= 5.0.3"
}

Recommendation: Always pin a module version with a constraint. This ensures reproducible deployments and avoids surprises during automatic updates.

4.3 Publishing a module to the Terraform Registry

sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub
    participant REG as Terraform Registry

    Dev->>GH: 1. Create public repo\nnamed: terraform-<PROVIDER>-<NAME>
    Dev->>GH: 2. Push module files\n(main.tf, variables.tf, outputs.tf, README.md)
    Dev->>GH: 3. Create a release tag\n(v1.0.0 — semver format)
    Dev->>REG: 4. Sign in with GitHub account
    Dev->>REG: 5. Publish > choose the repository
    REG->>GH: Scan release tags
    REG-->>Dev: Module published and available
    Note over REG: The Registry uses release tags\nto identify published versions

Mandatory naming convention for the public Registry:

terraform-<PROVIDER>-<MODULE_NAME>

Examples:
  terraform-aws-web_front_end
  terraform-aws-vpc
  terraform-google-kubernetes-engine
  terraform-azurerm-network

After publishing, the module is accessible with the short source:

module "web_front_end" {
  source  = "globomantics/web_front_end/aws"
  version = "~> 1.0"
  # ...
}

4.4 Refactoring and module updates

The moved block lets you refactor a resource address without destroying and recreating state:

# Scenario 1: rename a resource within a module
moved {
  from = aws_security_group.nlb
  to   = aws_security_group.nlb_sg
}

# Scenario 2: move a resource from root module to a child module
moved {
  from = aws_lb.front_end
  to   = module.web_front_end.aws_lb.front_end
}

# Scenario 3: rename a module
moved {
  from = module.old_web_frontend
  to   = module.web_front_end
}

4.5 Updating a module (feature addition)

Example: add support for custom tags on instances (minor update → version 1.1.0).

# modules/web-front-end/variables.tf — new optional variable
variable "instance_tags" {
  type        = map(string)
  description = "Custom tags to apply to EC2 instances"
  default     = {}  # optional = minor update, not breaking
}
# modules/web-front-end/main.tf — use with merge()
resource "aws_launch_template" "front_end" {
  # ...
  tag_specifications {
    resource_type = "instance"
    tags = merge(
      {
        Name        = "${var.prefix}-${var.environment}-ec2-instance"
        Environment = var.environment
      },
      var.instance_tags  # custom tags override on same key
    )
  }
}

The consumer can now use the new feature optionally:

module "web_front_end" {
  source  = "globomantics/web_front_end/aws"
  version = "~> 1.1"  # update to minor version

  # ...

  # Optional — not required for existing configurations
  instance_tags = {
    Team    = "platform-team"
    Project = "storefront-v2"
  }
}

5. Reference Tables

5.1 Module sources

Source typeFormatSupports versionUse case
Local filesystem./modules/nameProject-private modules
Terraform Public Registryorg/module/providerCommunity modules
Terraform Private Registryhostname/org/module/providerEnterprise modules
GitHubgithub.com/org/repo❌ (?ref=)Sharing without registry
Generic Gitgit::https://...?ref=❌ (?ref=)Private Git repos
Bitbucketbitbucket.org/org/repoBitbucket cloud/server
HTTP archivehttps://url/module.zipSimple distribution
S3 buckets3::https://...Internal AWS storage
GCS bucketgcs::https://...Internal GCP storage

5.2 Module block meta-arguments

Meta-argumentTypeDescription
sourcestring (required)Module origin — literal value only
versionstringVersion constraint — only for Terraform Registry
countnumberCreate N instances of the module
for_eachmap / setCreate one module instance per element
depends_onlist(ref)Explicit dependencies
providersmapMap parent → child provider instances

5.3 Change categories by semver

TypeSemverExamplesCode update required?
Bug fix / PatchPATCH (x.x.N)Fix for an unexpected error❌ No
Minor updateMINOR (x.N.0)New optional variable, new output❌ No
Major updateMAJOR (N.0.0)Required variable renamed/removed, resource replaced, minimum provider version incremented✅ Yes

5.4 Best practices

PracticeDescription
Pin versionsAlways use version = "~> x.y" for Registry modules
README.md requiredDocument inputs, outputs, and usage examples
Use terraform-docsGenerate documentation automatically
Dependency inversionModule receives dependencies via variables, not by accessing them directly
Module compositionCombine multiple modules in the root module rather than putting everything in one
One module = one conceptDon’t create catch-all modules; each module should have a clear role
Optional variables with defaultOptional features = variables with default value → minor update
moved for refactoringUse the moved block to change resource addresses without recreating state
ChangelogMaintain a CHANGELOG.md in the module repo
examples/ directoryProvide concrete usage examples
tests/ directoryInclude Terraform tests to validate the module
Local source = relative valuePrefer ./modules/name over an absolute path for portability
terraform init after addingAlways rerun terraform init after adding/modifying a module source

Search Terms

terraform · infrastructure · ci/cd · devops · child · outputs · providers · block · defining · design · inheritance · inputs · publishing · refactoring · registry · resources · root · sources · versioning

Interested in this course?

Contact us to book it or get a custom training plan for your team.