Day 9 - I : Advanced Terraform Modules: Versioning and Multi-Environment Deployment

Day 9 - I : Advanced Terraform Modules: Versioning and Multi-Environment Deployment

Day 8 got modules working. Day 9 is about making them safe to change — versioning a reusable web-app module, publishing it to GitHub, and deploying it across dev, staging, and production with controlled version promotion so a single update never breaks everything at once.

Day 8 introduced modules as a way to stop copy-pasting infrastructure code. Today goes one level further: what happens when the module itself changes? How do you roll out a module update to dev first, verify it, then promote to staging and prod — without everything changing at once?

That's module versioning, and it's what makes modules safe to use in a real team.

Why This Matters in the Industry

Imagine your team publishes an internal web-app module that 10 services use. You need to update the health check timeout. Without versioning, updating the module updates every service simultaneously — no testing, no gradual rollout, no rollback path if something breaks.

With versioning, the change is tagged as v1.1.0. Dev environments upgrade first, staging follows after validation, prod upgrades only after staging confirms it's clean and safe. Each environment is pinned to a specific version, so there are no surprises.

This is standard practice at any company where multiple teams share Terraform modules. It's also what the Terraform Registry enforces — every public module requires semantic versioning.

Step 1: The Module (terraform-modules repo)

Before jumping into code, here's how the two repositories are laid out. Keeping the module separate from the infrastructure that consumes it is what makes versioning possible — you can tag the module repo independently and each environment pins its own version.

terraform-modules/                  ← module repository (published to GitHub)
└── modules/
    └── web-app/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

terraform-infra/                    ← infrastructure repository (consumes the module)
├── dev/
│   ├── main.tf                     # calls module v1.0.0
│   ├── variables.tf
│   ├── terraform.tfvars
│   └── backend.tf
├── staging/
│   ├── main.tf                     # calls module v1.0.0
│   ├── variables.tf
│   ├── terraform.tfvars
│   └── backend.tf
└── prod/
    ├── main.tf                     # calls module v1.0.0
    ├── variables.tf
    ├── terraform.tfvars
    └── backend.tf

The module itself is the same web-app from Day 8, with one enhancement: a health_check_path variable so callers can customize the health check endpoint without touching the module internals.

modules/web-app/variables.tf

variable "environment" {
  description = "Deployment environment"
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t2.micro"
}

variable "min_size" {
  description = "Minimum number of instances in the ASG"
  type        = number
  default     = 1
}

variable "max_size" {
  description = "Maximum number of instances in the ASG"
  type        = number
  default     = 2
}

variable "server_port" {
  description = "Port the web server listens on"
  type        = number
  default     = 8080
}

variable "health_check_path" {
  description = "Path the ALB uses for health checks"
  type        = string
  default     = "/"
}

modules/web-app/main.tf

locals {
  name_prefix = "web-app-${var.environment}"

  common_tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
    Module      = "web-app"
  }
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}

data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

resource "aws_security_group" "instance" {
  name = "${local.name_prefix}-instance-sg"

  ingress {
    from_port   = var.server_port
    to_port     = var.server_port
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = local.common_tags
}

resource "aws_launch_template" "web" {
  image_id      = data.aws_ami.amazon_linux.id
  instance_type = var.instance_type

  vpc_security_group_ids = [aws_security_group.instance.id]

  # base64encode() is required for aws_launch_template —
  # unlike the deprecated aws_launch_configuration, it does not encode automatically
  user_data = base64encode(<<-EOF
    #!/bin/bash
    mkdir -p /var/www/html
    echo "Hello from ${var.environment}" > /var/www/html/index.html
    cd /var/www/html && nohup python3 -m http.server ${var.server_port} &
  EOF
  )

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "alb" {
  name = "${local.name_prefix}-alb-sg"

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = local.common_tags
}

resource "aws_lb" "web" {
  name               = "${local.name_prefix}-alb"
  load_balancer_type = "application"
  subnets            = data.aws_subnets.default.ids
  security_groups    = [aws_security_group.alb.id]

  tags = local.common_tags
}

resource "aws_lb_target_group" "web" {
  name     = "${local.name_prefix}-tg"
  port     = var.server_port
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id

  health_check {
    path                = var.health_check_path
    protocol            = "HTTP"
    matcher             = "200"
    interval            = 15
    timeout             = 3
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.web.arn
  }
}

resource "aws_autoscaling_group" "web" {
  min_size         = var.min_size
  max_size         = var.max_size
  desired_capacity = var.min_size

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

  vpc_zone_identifier = data.aws_subnets.default.ids
  target_group_arns   = [aws_lb_target_group.web.arn]
  health_check_type   = "ELB"

  tag {
    key                 = "Name"
    value               = "${local.name_prefix}-web"
    propagate_at_launch = true
  }
}

modules/web-app/outputs.tf

output "alb_dns_name" {
  value       = aws_lb.web.dns_name
  description = "DNS name of the Application Load Balancer"
}

output "asg_name" {
  value       = aws_autoscaling_group.web.name
  description = "Name of the Auto Scaling Group"
}

output "alb_security_group_id" {
  value       = aws_security_group.alb.id
  description = "Security group ID of the ALB — useful for adding ingress rules from other resources"
}

Step 2: Publish the Module to GitHub

Push the terraform-modules repo to GitHub and create a release tag. Semantic versioning is the convention — v1.0.0 for the initial release:

git init
git add .
git commit -m "feat: initial web-app module v1.0.0"
git remote add origin git@github.com:mohamednourdine/terraform-modules.git
git push -u origin main

# Tag the release
git tag v1.0.0
git push origin v1.0.0

Once tagged, the module is consumable by anyone with access to the repo — and upgrades are controlled by changing the ref in the source.

Step 3: The Infrastructure Repo (terraform-infra)

Before wiring up the backend, the S3 bucket and DynamoDB table have to exist. Terraform cannot bootstrap its own remote state — the backend must be in place before terraform init can run. This is a one-time manual setup per AWS account, done with the CLI:

# Create the bucket — bucket names are globally unique, pick something specific to your account
aws s3api create-bucket \
  --bucket mnourdine-tf-state \
  --region us-east-1

# Enable versioning — lets you recover a previous state if something goes wrong
aws s3api put-bucket-versioning \
  --bucket mnourdine-tf-state \
  --versioning-configuration Status=Enabled

# Encrypt state at rest — state files can contain sensitive values
aws s3api put-bucket-encryption \
  --bucket mnourdine-tf-state \
  --server-side-encryption-configuration \
    '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'

# Block all public access
aws s3api put-public-access-block \
  --bucket mnourdine-tf-state \
  --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

# Create the DynamoDB table for state locking
# LockID is the required hash key — that's the key Terraform writes to when acquiring a lock
aws dynamodb create-table \
  --table-name terraform-state-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region us-east-1

Run these once. After that, every terraform init in every environment can point at the same bucket and table.

Each environment lives in its own folder with its own backend and a terraform.tfvars file for environment-specific values. All three point to the same module, pinned to v1.0.0.

dev/backend.tf

terraform {
  backend "s3" {
    bucket         = "mnourdine-tf-state"
    key            = "dev/web-app/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-state-locks"
    encrypt        = true
  }
}

dev/terraform.tfvars — environment-specific values, kept out of main.tf

environment   = "dev"
instance_type = "t2.micro"
min_size      = 1
max_size      = 2

dev/main.tf — calls the module, pinned to v1.0.0

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

module "web_app" {
  source = "git::https://github.com/mohamednourdine/terraform-modules.git//modules/web-app?ref=v1.0.0"

  environment       = var.environment
  instance_type     = var.instance_type
  min_size          = var.min_size
  max_size          = var.max_size
  health_check_path = "/"
}

output "url" {
  value = module.web_app.alb_dns_name
}

dev/variables.tf — declares the variables that tfvars will populate

variable "environment"       { type = string }
variable "instance_type"     { type = string }
variable "min_size"          { type = number }
variable "max_size"          { type = number }

Staging and prod follow the same structure, just with different terraform.tfvars values:

staging/terraform.tfvars

environment   = "staging"
instance_type = "t2.small"
min_size      = 1
max_size      = 3

prod/terraform.tfvars

environment   = "prod"
instance_type = "t3.small"
min_size      = 2
max_size      = 6

Step 4: Deploying All Three Environments

# Dev
cd dev/
terraform init
terraform apply -var-file="terraform.tfvars"

# Staging
cd ../staging/
terraform init
terraform apply -var-file="terraform.tfvars"

# Prod
cd ../prod/
terraform init
terraform apply -var-file="terraform.tfvars"

Each outputs its own ALB DNS name:

Outputs:
url = "web-app-dev-alb-xxxxxxxx.us-east-1.elb.amazonaws.com"

Three environments, three isolated state files, all from the same module version.

Step 5: Releasing a New Module Version

Say I want to increase the health check interval from 15s to 30s across all environments. I update the module, commit, and tag v1.1.0:

modules/web-app/main.tf — updated health check

health_check {
  path                = var.health_check_path
  protocol            = "HTTP"
  matcher             = "200"
  interval            = 30   # was 15
  timeout             = 5    # was 3
  healthy_threshold   = 2
  unhealthy_threshold = 2
}
git add .
git commit -m "fix: increase health check interval to 30s"
git tag v1.1.0
git push origin main --tags

Now I upgrade environments one at a time. Dev first:

dev/main.tf — bumped to v1.1.0

module "web_app" {
  source = "git::https://github.com/mohamednourdine/terraform-modules.git//modules/web-app?ref=v1.1.0"
  # ...
}
cd dev/
terraform init -upgrade   # re-downloads the new module version
terraform apply

Dev is on v1.1.0. Staging and prod are still on v1.0.0. Once dev looks good, promote to staging, then prod — each as a separate, deliberate change.

This is the controlled rollout that makes module versioning worth the setup.

Module Gotchas

A few things that tripped me up and are worth knowing before running into them:

terraform init doesn't automatically pick up module changes. If you update the ref in the source, you need to run terraform init -upgrade to download the new version. Just terraform plan won't do it — it'll keep using the cached version.

Module outputs can't reference each other directly. If module A depends on an output from module B, you pass it through the root config explicitly:

module "networking" {
  source = "..."
}

module "web_app" {
  source = "..."
  vpc_id = module.networking.vpc_id   # passed explicitly from root
}

Renaming a module breaks state. If you rename module "web_app" to module "web_service", Terraform treats it as a destroy + create. Use terraform state mv first:

terraform state mv module.web_app module.web_service

Then rename in the config and apply safely.

Where I'm At

Versioning was the missing piece that makes modules usable at scale. Without it, a module is just a shared file — useful but fragile, because any change affects everyone immediately. With versioning, a module becomes a proper contract: consumers choose when to upgrade, upgrades are tested environment by environment, and rollback is just pinning back to the previous tag.

The hands-on project today made it concrete. Three environments, one module repo, controlled version promotion. That's a pattern I'll carry into real projects.


This post is part of a 30-day Terraform learning journey.

Share This Article

Did you find this helpful?

💬 Comments

No comments yet. Be the first to share your thoughts!

Leave a Comment

Get In Touch

I'm always open to discussing new projects and opportunities.

Location Yassa/Douala, Cameroon
Availability Open for opportunities

Connect With Me

Send a Message

Have a project in mind? Let's talk about it.