Skip to content

Terraform Modules and Reusable Infrastructure — Complete Guide with Best Practices

DodaTech Updated 2026-06-22 8 min read

In this tutorial, you'll learn about Terraform Modules and Reusable Infrastructure. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Terraform modules are self-contained packages of Terraform configuration that encapsulate related infrastructure resources, enabling teams to reuse, compose, and version infrastructure across multiple environments and projects.

What You'll Learn

Why It Matters

Copy-pasting Terraform code across projects leads to configuration drift, security inconsistencies, and maintenance nightmares. When a security group rule needs updating, you either miss 10 of 50 copy-pasted instances or spend days auditing. Modules solve this by defining infrastructure once and referencing it with different inputs. A single module update propagates to all consumers with a version bump.

Real-World Use

DodaTech maintains a library of 30+ Terraform modules for AWS infrastructure (VPC, EKS, RDS, S3, IAM) shared across teams. The Durga Antivirus Pro backend uses the eks-cluster module with environment = "production", referencing version 4.2.0 from a private registry. Upgrading Kubernetes versions is a single version change in the module source.

flowchart TD
    A["Root Module: production"] --> B["Module: vpc v2.1.0"]
    A --> C["Module: eks v4.2.0"]
    A --> D["Module: rds v3.0.0"]
    A --> E["Module: s3 v1.5.0"]
    B --> F["AWS: VPC, Subnets, Routes"]
    C --> G["AWS: EKS Cluster, Node Groups"]
    D --> H["AWS: RDS Instance, Subnet Group"]
    E --> I["AWS: S3 Bucket, Policies"]
    J["Terraform Registry"] --> B
    J --> C
    J --> D
    J --> E
    K["module.vpc.outputs.vpc_id"] --> L["module.eks.inputs.vpc_id"]
    K --> M["module.rds.inputs.subnet_ids"]
    style A fill:#7B42BC,color:#fff
    style B fill:#326CE5,color:#fff
    style C fill:#326CE5,color:#fff
    style D fill:#326CE5,color:#fff
â„šī¸ Info

Prerequisites: Basic Terraform knowledge (resources, variables, state), AWS or cloud provider account, and Terraform 1.5+ installed.

Module Structure

A standard module follows the recommended directory structure with well-defined inputs and outputs.

terraform-aws-vpc/
  README.md             # Documentation with examples
  LICENSE               # License file (MIT, Apache 2.0)
  main.tf               # Primary resources
  variables.tf          # Input variable declarations
  outputs.tf            # Output value declarations
  versions.tf           # Terraform and provider version constraints
  examples/
    basic/              # Simple usage example
      main.tf
      variables.tf
      outputs.tf
    multi-az/           # Advanced usage example
      main.tf
  tests/
    basic_test.go       # Terratest integration tests
# variables.tf
variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "Must be a valid CIDR block."
  }
}

variable "environment" {
  description = "Environment name (dev, staging, production)"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be dev, staging, or production."
  }
}

variable "enable_nat_gateway" {
  description = "Whether to deploy a NAT Gateway"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Resource tags"
  type        = map(string)
  default     = {}
}

Expected behavior: Variables enforce type constraints and validation rules. The vpc_cidr validation ensures the input is a valid CIDR notation. The environment variable restricts inputs to a controlled set of allowed values, preventing typos like "produktion".

# main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(var.tags, {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  })
}

resource "aws_subnet" "public" {
  count             = length(var.public_subnet_cidrs)
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]
  map_public_ip_on_launch = true

  tags = merge(var.tags, {
    Name        = "${var.environment}-public-${count.index + 1}"
    Environment = var.environment
    Tier        = "public"
  })
}

resource "aws_eip" "nat" {
  count  = var.enable_nat_gateway ? 1 : 0
  domain = "vpc"
}

resource "aws_nat_gateway" "this" {
  count         = var.enable_nat_gateway ? 1 : 0
  allocation_id = aws_eip.nat[0].id
  subnet_id     = aws_subnet.public[0].id

  tags = merge(var.tags, {
    Name        = "${var.environment}-nat"
    Environment = var.environment
  })
}
Output Name Description Value
vpc_id The VPC ID aws_vpc.this.id
public_subnet_ids Public subnet IDs aws_subnet.public[*].id
private_subnet_ids Private subnet IDs aws_subnet.private[*].id
nat_gateway_ips NAT Gateway EIPs aws_eip.nat[*].public_ip

Using Modules with Version Constraints

# production/main.tf
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

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

  name = "production-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway     = true
  enable_vpn_gateway     = false
  enable_dns_hostnames   = true

  tags = {
    Environment = "production"
    Terraform   = "true"
  }
}

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.0"

  cluster_name    = "production-eks"
  cluster_version = "1.28"

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets

  node_groups = {
    main = {
      desired_capacity = 3
      max_capacity     = 20
      min_capacity     = 2
      instance_types   = ["t3.medium"]
    }
  }

  tags = {
    Environment = "production"
  }
}

Expected behavior: Terraform fetches module versions 5.x for VPC and 19.x for EKS from the Terraform Registry. Module outputs from module.vpc feed into module.eks. The ~> constraint allows patch version updates (5.0.1, 5.0.2) but blocks major or minor version upgrades (5.1.0, 6.0.0).

Remote State with Terraform Cloud

Store state remotely to enable team collaboration and state locking.

# production/versions.tf
terraform {
  backend "s3" {
    bucket         = "dodatech-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}
# Initialize with remote state
terraform init

# Expected output:
# Initializing the backend...
# Successfully configured the backend "s3"!
# Terraform has been successfully initialized!

# Plan with remote state
terraform plan -out=tfplan

# Apply
terraform apply tfplan

Module Testing with Terratest

// tests/vpc_test.go
package test

import (
    "testing"

    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestVPCCreation(t *testing.T) {
    terraformOptions := &terraform.Options{
        TerraformDir: "../examples/basic",
        Vars: map[string]interface{}{
            "vpc_cidr": "10.0.0.0/16",
            "environment": "test",
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    vpcID := terraform.Output(t, terraformOptions, "vpc_id")
    assert.NotEmpty(t, vpcID, "VPC ID should not be empty")
}

Common Errors

  1. Hard-coding values inside modules instead of using variables: A module with hard-coded values is not reusable. Every configurable value -- CIDR blocks, instance types, tags, feature flags -- must be an input variable with a sensible default.

  2. Not pinning module versions: Using source = "git::https://...//modules/vpc?ref=main" without a version tag causes every <a href="/devops/terraform/">Terraform</a> init to potentially pull different code. Always pin to a specific Git tag, release version, or semver constraint.

  3. Circular module dependencies: Module A references output from Module B, and Module B references output from Module A. Terraform cannot resolve this graph. Extract the common dependency into a third module that both A and B consume.

  4. Exposing sensitive data in module outputs: Module outputs are stored in state in plaintext. If a module outputs db_master_password, that password is visible in the state file. Use sensitive = true in output declarations and avoid outputting secrets altogether.

  5. Module sprawl without a registry Strategy: Without a module registry (public, private, or Git-based), teams lose visibility into available modules and create duplicates. Establish a registry Strategy early, whether using Terraform Cloud's private registry, a simple Git Repository convention, or a dedicated module registry tool.

Practice Questions

  1. What is the difference between a root module and a child module? Answer: A root module is the working directory where <a href="/devops/terraform/">Terraform</a> apply is executed. It has a backend configuration. A child module is called from a root module using a module block. Child modules cannot have their own backend configuration.

  2. How does the source argument work in a module block? Answer: The source tells Terraform where to find the module's configuration. Supported sources include: local paths (./modules/vpc), Terraform Registry (<a href="/devops/terraform/">Terraform</a>-aws-modules/vpc/aws), GitHub (github.com/org/repo//path), Git HTTPS, S3, and HTTP URLs.

  3. Why should modules have version constraints? Answer: Version constraints prevent accidental upgrades that could introduce breaking changes. A ~> 5.0 constraint allows patch releases (5.0.1, 5.0.2) but blocks 5.1.0 (may include breaking changes) and 6.0.0 (definitely breaking).

  4. What is the purpose of count and for_each in modules? Answer: count and for_each allow a single module block to create multiple instances of the module's resources. count works with a numeric index. for_each works with a map or set, providing more meaningful keys. Both enable dynamic infrastructure creation based on variable input.

Challenge

Refactor a monolithic Terraform configuration for a web application (VPC, EKS, RDS, S3) into reusable modules. Create three modules: networking (VPC, subnets, NAT), compute (EKS cluster, node groups), and storage (RDS, S3 buckets). Each module should have typed variables with validation, comprehensive outputs, and sensible defaults. Create root modules for dev, staging, and production environments that consume these modules with different inputs. Add Terratest tests for the networking module. Publish the modules to a private Git Repository with semantic versioning tags.

Mini Project

Build a complete reusable infrastructure module library: write a <a href="/devops/terraform/">Terraform</a>-aws-ecs-service module that takes a container definition, target group ARN, and security group IDs as inputs and creates an ECS service with Fargate launch type, auto-scaling, and service discovery. Write a <a href="/devops/terraform/">Terraform</a>-aws-rds-cluster module for Aurora with read replicas, automated backups, and enhanced monitoring. Create root modules for three environments consuming these modules. Write Terratest integration tests for both modules. Set up a CI/CD pipeline that runs <a href="/devops/terraform/">Terraform</a> fmt, <a href="/devops/terraform/">Terraform</a> validate, and tflint on every Pull Request, and publishes new module versions on merge to main.

Resource Description
Terraform Basics Core Terraform concepts
Infrastructure as Code IaC best practices
GitOps Workflow Managing IaC with GitOps
CI/CD Pipelines Automating Terraform in CI/CD

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro