Skip to content

Intermediate · Infrastructure as code with Terraform · topic 33 of 53 · 35 min

Modules, environments and Terraform in CI

Explanation

A module is a folder of Terraform files you can reuse with different inputs, like a function. You call child modules from your root module, from a local path or from the Terraform Registry with a pinned version. Teams keep separate state per environment, tag every resource, commit .terraform.lock.hcl, and run fmt, validate and plan in CI on every pull request, with apply only after merge and approval. OpenTofu is the open-source fork of Terraform.

What a module is

Every folder of .tf files is a module. The folder where you run terraform is the root module. A root module can call other modules, called child modules, to reuse a group of resources. Think of a module as a function: variables are its parameters, outputs are its return values, and the resources inside are its body.

Think of it like this: A module is like a standard flat design used by a builder. The same plan (two bedrooms, kitchen, balcony) is built on many floors. Each floor gets small inputs, such as the paint colour and flat number, but nobody redraws the plumbing each time.
output
modules/vpc/
  main.tf        # the resources: VPC, subnets, route tables
  variables.tf   # inputs: name, cidr_block, azs, public_subnets ...
  outputs.tf     # outputs: vpc_id, public_subnet_ids ...
  versions.tf    # required_providers
  README.md
A common layout for a child module.

Calling a module

output
module "vpc" {
  source = "./modules/vpc"     # a local path
  name       = "portal-${var.environment}"
  cidr_block = "10.20.0.0/16"
  azs        = ["ap-south-1a", "ap-south-1b"]
}

module "network" {
  source  = "terraform-aws-modules/vpc/aws"   # from the public Registry
  version = "~> 5.0"                          # pin a version you have tested
  # ... inputs ...
}

resource "aws_instance" "app" {
  subnet_id = module.vpc.public_subnet_ids[0]   # read a module output
  # ...
}
Terraform (HCL): after adding or changing a module source, run terraform init again.

source can be a local path, a Git URL or a Registry address. Registry modules accept a version argument; always pin it, so an update cannot change your infrastructure without a review. The public Terraform Registry (registry.terraform.io) lists providers and community modules. Read a module's code before you trust it with production.

Structuring environments

You need dev, staging and production that are alike but separate. There are two common ways.

ApproachHowGoodWatch out
Directory per environmentenvs/dev, envs/staging, envs/prod, each a small root module calling shared modules, each with its own backend keyClear, separate state and permissions per environment; prod can pin an older module versionSome repetition between folders
WorkspacesOne folder; terraform workspace new staging; state is stored per workspaceLess codeEasy to apply to the wrong workspace; same backend and credentials for all environments

Many teams choose a directory per environment for production systems, because a mistake in dev cannot touch prod state, and access can differ. Whatever you choose, keep one state per environment (and often per component, such as network and database) so the "blast radius" of a bad apply stays small.

Naming and tagging

Use one naming pattern, such as project-environment-component (portal-prod-db). Tag every resource with at least project, environment, owner and cost centre. Tags let finance split the cloud bill by team and let engineers find who owns a forgotten server. On AWS, the provider's default_tags setting adds tags to every resource automatically.

The dependency lock file

terraform init writes .terraform.lock.hcl, which records the exact provider versions and checksums chosen. Commit it, so every laptop and CI runner uses the same provider build. Run terraform init -upgrade on purpose when you want newer versions, and review the change.

Terraform in CI/CD

  1. On every pull request: terraform fmt -check, terraform validate, a linter such as tflint, and a security scanner such as Checkov.
  2. Run terraform plan and post the plan as a comment on the pull request, so reviewers see the real effect.
  3. After merge to main: apply, ideally the saved plan file. For production, require a manual approval step.
  4. Give CI short-lived cloud credentials (for example OIDC federation) instead of long-lived keys stored in the repo.
bash
terraform fmt -check -recursive
terraform init -input=false
terraform validate
tflint
checkov -d .
terraform plan -input=false -out=tfplan
# after merge and approval:
terraform apply -input=false tfplan
A typical pipeline. -input=false makes Terraform fail instead of waiting for keyboard input.

Policy as code means writing rules that every plan must pass, such as "no S3 bucket may be public" or "every resource needs an owner tag". Checkov and tfsec-style scanners have many built-in rules. Open Policy Agent (OPA) lets you write your own rules in a language called Rego; HCP Terraform also supports policy checks.

Terraform and OpenTofu

In August 2023 HashiCorp changed Terraform's licence from the open-source MPL to the Business Source License (BSL 1.1), which limits some commercial uses. In response, the community created OpenTofu, an open-source fork now hosted by the Linux Foundation. Its command is tofu (tofu init, tofu plan, tofu apply), and it reads the same HCL files and providers. The two have since added some different features, so check each tool's documentation before switching. For learning, the ideas in this module apply to both.

Common mistakes

Careful: Unpinned module and provider versions. A new major version can rename arguments or replace resources. Pin versions, commit the lock file, and upgrade on purpose in a pull request.
Careful: One giant state for everything. A single bad apply can then touch the network, database and DNS at once, and plans become slow. Split by environment and by component.
Careful: Modules that are too clever: dozens of inputs, deep nesting, modules that only wrap one resource. Start with simple code; extract a module when the same group of resources appears a second time.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Modules, environments and Terraform in CI · 1/7

Modules and teamwork

In this lesson you will reuse Terraform code with modules and run Terraform safely as a team.

Prefer a video by a teacher? Find free videos on “terraform modules environments ci cd pipeline best practices” on YouTube ↗

In real life: One VPC module, three environments, at a fintech

A payments startup in Gurugram has a vpc module in its platform repo. envs/dev, envs/staging and envs/prod each call it with a different CIDR range and their own S3 state key. Pull requests run fmt, validate, tflint, Checkov and plan for every environment touched, and the plan appears as a PR comment. Merges apply dev and staging automatically; prod waits for a lead engineer to approve in the pipeline. Every resource carries project, environment, owner and cost-centre tags, so the monthly AWS bill is split by team.

output
envs/
  dev/main.tf       -> module "vpc" { source = "../../modules/vpc"  cidr_block = "10.10.0.0/16" }
  staging/main.tf   -> module "vpc" { ... cidr_block = "10.20.0.0/16" }
  prod/main.tf      -> module "vpc" { ... cidr_block = "10.30.0.0/16" }
modules/
  vpc/              -> main.tf, variables.tf, outputs.tf
Directory per environment, one shared module.

Practice (0/3 done)

Task 1 · Question

Inside a root module, how do you read the output vpc_id of a module called "vpc"?

Task 2 · Code · Python

A tagging policy check. resources maps a resource name to its tags dict. required is a list of tag keys every resource must have. Write missing_tags(resources, required) that returns a dict mapping each non-compliant resource name to a sorted list of its missing tag keys. A tag whose value is an empty string counts as missing. Compliant resources are left out.

Task 3 · Practice

Design a reusable VPC module for AWS. Write its variables.tf (inputs with types, descriptions and sensible defaults where safe) and outputs.tf, and show how envs/prod/main.tf would call it. You do not need to write every resource in main.tf; list which resources it would contain.

Quick quiz

Question 1

1.Why pin the version of a Registry module?

Question 2

2.What is a main risk of using workspaces for dev and prod?

Question 3

3.What should happen to .terraform.lock.hcl?

Question 4

4.Which step usually runs on a pull request, before merge?

Question 5

5.What is OpenTofu?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

Why pin the version of a Registry module?

Answer: So an upstream update cannot change your infrastructure without review

Pinning means updates happen only when you change the version in a reviewed pull request.

What is a main risk of using workspaces for dev and prod?

Answer: It is easy to run apply in the wrong workspace, and all environments share one backend and credentials

Workspaces share code, backend and usually credentials, so a slip can hit prod.

What should happen to .terraform.lock.hcl?

Answer: Commit it to Git

The lock file pins exact provider versions and checksums for everyone, so it belongs in Git.

Which step usually runs on a pull request, before merge?

Answer: terraform plan, with the output shown to reviewers

Reviewers should see the plan before anything changes; apply comes after merge and approval.

What is OpenTofu?

Answer: An open-source fork of Terraform under the Linux Foundation, run with the tofu command

OpenTofu was forked after Terraform moved to the Business Source License in 2023.

Why should terraform.tfstate not be committed to Git?Related

Answer: It can contain secrets in plain text and it must be shared with locking, which Git cannot do

State can hold passwords and keys, and it needs locking to stop two applies at once. Use an encrypted remote backend.

From Terraform for the cloud

How do you refer to the id of resource "aws_s3_bucket" "logs"?Related

Answer: aws_s3_bucket.logs.id

A resource address is TYPE.NAME, and you add the attribute after another dot.

From Terraform basics: HCL, providers, resources and the workflow

Your company has 200 resources that were created in the console. What is the usual way to bring them under Terraform?Related

Answer: Import them (import blocks or terraform import), then plan until it shows no changes

Importing adds existing resources to the state; a clean plan proves the code matches them.

From IaC in a team: reviews, pipelines and policy

What does terraform state rm aws_s3_bucket.legacy do to the real bucket?Related

Answer: Nothing: Terraform just stops tracking it

state rm only removes the record. The real resource keeps existing.

From Terraform state, backends and drift

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. This is the last topic of “Infrastructure as code with Terraform”. The module exam comes next.
  2. Module 7 exam: Infrastructure as code with Terraform · 13 questions · 20 min, without help. 4 topics of this module still to finish first.
  3. Then the next module, Configuration management with Ansible, and YAML. 53 topics left in the course before the final exam.

← Previous: Terraform state, backends and drift