Create your own
Lesson illustration

Designing Reusable Terraform Modules and Isolated Environments

Hello. This course is a focused senior-level interview sprint: the goal is not merely to write Terraform, but to explain how Terraform becomes a safe, scalable operating model for production AWS infrastructure. In this module, we will treat Terraform modules as internal platform products and Terraform state as an operational boundary with real blast-radius, access-control, and ownership implications.

By the end of this lesson, you should be able to propose a Terraform layout for an exchange-style AWS platform, explain why its modules and states are separated as they are, and defend the versioning and team-ownership model in an interview.


1. Start with boundaries, not folders

A common Terraform failure mode is beginning with resources: writing a VPC, then subnets, then ECS, then RDS, until one repository and one state file own the entire platform. The configuration may work initially, but it creates a large operational unit: a routine application change can refresh, lock, or potentially affect foundational network and data resources.

At lead level, start with four design questions:

  1. What resources always change together?
    This is the module’s functional boundary. A VPC, its subnets, route tables, NAT configuration, and endpoints are usually one cohesive network capability.

  2. Who is permitted to change them?
    This is the privilege boundary. Network, IAM, and database changes normally need more restrictive access and review than application service changes.

  3. How often do they change?
    This is the volatility boundary. A database topology should not be exposed to deployment-frequency churn from an ECS service.

  4. What is the acceptable blast radius of a Terraform apply?
    This is the state boundary. A state file is not just a technical artifact; it defines the set of objects Terraform can refresh and mutate in one operation.

The HashiCorp module-boundary example provides a useful initial shape for this reasoning.

A conceptual AWS platform split into Security, Routing, Network, Web, App, and Database modules. The separation reflects differing privileges, ownership, and rates of change rather than merely grouping resources by AWS service.

For an exchange platform, the diagram should be interpreted as a starting model, not a rigid template. For example, a public trading API service may have its own service-level configuration and state, while shared network, IAM baseline, DNS, and database layers remain independently managed.

Meet the experts: Terraform module design

Watch “Meet the experts: Terraform module design” from HashiCorp, an IBM Company. It frames module design as product and API design: first establish who the consumers are and what problem the module is intended to solve.

Watch module scoping to hear why stakeholder and consumer needs should define module scope. Then watch input contracts, focusing on the idea that variables are the module’s API: required inputs should be deliberate, while optional behavior needs safe defaults, validation, and documentation.

A practical module rule

A module should provide a useful opinionated abstraction, not become a thin wrapper around every provider argument.

For example, an internal terraform-aws-ecs-service module could require:

  • Cluster identifier
  • Private subnet IDs
  • Security group IDs
  • Container image reference
  • CPU and memory
  • Desired-count and autoscaling bounds
  • Secret references
  • Standard tags

It should apply organizational defaults such as encrypted logging, consistent tagging, health-check conventions, and deployment-controller defaults. It should not expose every possible aws_ecs_service or aws_lb_target_group argument on day one. Excess configurability makes modules difficult to test, document, upgrade, and support.

Conversely, do not create a module merely because there is a resource. A one-resource module that adds no reusable policy, interface, or lifecycle boundary often obscures rather than simplifies the system.


2. Separate module code from live infrastructure

There are two distinct things in a mature Terraform operating model:

  • Reusable modules: versioned building blocks maintained as products.
  • Root configurations: environment-specific code that selects module versions, supplies values, configures providers, and owns state.

A credible structure for a multi-account AWS exchange platform could look like this:

terraform-aws-vpc/
terraform-aws-ecs-service/
terraform-aws-rds-postgres/
terraform-aws-observability-baseline/
terraform-aws-workload-iam/

exchange-live-infrastructure/
  global/
    identity-baseline/
    shared-artifacts/
  nonprod/
    ap-south-1/
      network/
      data/
      trading-api/
      market-data-consumer/
  prod/
    ap-south-1/
      network/
      data/
      trading-api/
      market-data-consumer/

Each leaf directory under exchange-live-infrastructure is a root module with its own backend configuration and state. It invokes published reusable modules at explicitly selected versions.

This enables independent promotion. For example, a new ECS-service-module release can first be adopted by a non-production trading-api root, then later by the production root after review. The production environment is not silently changed simply because someone modified a local module folder.

State boundaries: choose operational units

A useful initial state model is:

State rootTypical contentsOwnerWhy it is separate
prod/.../networkVPC, subnets, routes, NAT, endpoints, shared network security controlsPlatform/network teamHigh privilege, low change frequency, broad blast radius
prod/.../identity-baselineAccount-level IAM baseline, audit configuration, shared rolesSecurity/platform teamSensitive permissions and compliance ownership
prod/.../dataRDS topology, subnet groups, parameter groups, backupsData/platform teamStateful assets need cautious, independently reviewed changes
prod/.../trading-apiECS service, service-level ALB configuration, task role attachments, scaling settingsApplication/platform teamChanges at deployment cadence and should not lock network or data state
prod/.../observabilityShared dashboards, alert routes, platform telemetry integrationsSRE/platform teamShared operational capability with distinct ownership

This is not a recommendation to create a state file for every resource. Over-splitting creates dependency confusion and a large burden of coordination. The goal is to place resources together when they share lifecycle, owner, privilege level, and deployment cadence.

A good test is: Would I be comfortable allowing this team’s normal change process to operate on every resource in this state? If the answer is no, the state boundary is probably too broad.

Environment isolation is more than workspaces

For a high-value production system, isolate environments primarily through AWS accounts, scoped IAM roles, and separate backend paths. Terraform workspaces can be useful for lightweight variants, but they are not a sufficient security or production-isolation mechanism on their own.

A production network root and a non-production network root should have:

  • Different AWS account targets.
  • Different remote-state keys.
  • Different deployment roles.
  • Different approval policies.
  • Environment-specific values such as CIDR allocations and scaling limits.

A remote S3 backend might be configured conceptually like this:

terraform {
  backend "s3" {
    bucket       = "company-terraform-state"
    key          = "prod/ap-south-1/network/terraform.tfstate"
    region       = "ap-south-1"
    encrypt      = true
    use_lockfile = true
  }
}

The backend bucket itself should be tightly controlled, versioned, encrypted, and accessible only to the relevant Terraform execution identities. Avoid placing credentials in backend configuration or committing them to the repository.

Style Guide - Configuration Language | Terraform

Read the Terraform Style Guide from HashiCorp for the operational rationale behind version pinning, separate module code, and isolated environments. These recommendations map directly to the architecture you need to defend in an interview.

In the “Workflow style” and “Version pinning” sections, read the versioning guidance. Focus on why upgrades must be deliberate infrastructure changes. Next, in “Repository structure,” read the repository rationale. Note the distinction between reusable module source code and deployed infrastructure configuration. Finally, in “Multiple environments,” read the environment guidance. Compare it with the explicit per-environment root directories described above.


3. Make versions and interfaces explicit

Terraform code is executable infrastructure change. Therefore, its dependencies must be controlled with the same discipline as application dependencies.

At the root-configuration level, explicitly select the Terraform binary version and AWS provider version:

terraform {
  required_version = ">= 1.7.0, < 1.8.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.34.0"
    }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      system      = "exchange-platform"
      environment = var.environment
      managed_by  = "terraform"
    }
  }
}

The root configuration owns the provider configuration because it knows the target environment, AWS account, region, and deployment identity. Reusable child modules should generally declare provider requirements but should not hardcode provider configuration, credentials, or backend settings.

For example, a reusable VPC module can define the range it is compatible with:

terraform {
  required_version = ">= 1.7.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.34.0, < 6.0.0"
    }
  }
}

The root configuration’s exact provider selection must satisfy the child module’s compatibility range. Commit .terraform.lock.hcl as well. That lock file records the resolved provider packages and prevents one engineer or CI runner from receiving a different provider build unexpectedly.

Pin reusable module releases

A root module should consume a release, not a mutable branch:

module "network" {
  source  = "app.terraform.io/company/network/aws"
  version = "3.2.1"

  environment        = var.environment
  vpc_cidr           = var.vpc_cidr
  availability_zones = var.availability_zones

  private_subnet_cidrs = var.private_subnet_cidrs
  tags                 = local.common_tags
}

Use semantic versioning as an operational communication mechanism:

ChangeRelease typeExample
Documentation correction or backward-compatible bug fixPatch3.2.1 to 3.2.2
New optional capability or outputMinor3.2.1 to 3.3.0
Removed output, renamed input, changed default with material impact, or newly mandatory inputMajor3.2.1 to 4.0.0

A newly mandatory variable is a breaking change even if the underlying AWS resources are unchanged. Existing module consumers cannot upgrade without changing their configuration.

Inputs are an API contract

Use types, clear descriptions, validation, and deliberate defaults. Required inputs should have no default. Optional inputs should have safe, documented behavior.

variable "vpc_cidr" {
  description = "IPv4 CIDR range allocated to the application VPC."
  type        = string

  validation {
    condition     = can(cidrnetmask(var.vpc_cidr))
    error_message = "vpc_cidr must be a valid IPv4 CIDR block."
  }
}

variable "availability_zones" {
  description = "Availability Zones used for multi-AZ subnet placement."
  type        = set(string)
}

variable "private_subnet_cidrs" {
  description = "Map of Availability Zone to private subnet CIDR."
  type        = map(string)
}

variable "enable_vpc_flow_logs" {
  description = "Whether to enable VPC Flow Logs using the platform default destination."
  type        = bool
  default     = true
}

variable "tags" {
  description = "Additional tags merged with mandatory platform tags."
  type        = map(string)
  default     = {}
}

A map is appropriate here because subnet definitions are naturally keyed by Availability Zone. Do not use complicated nested input objects solely to make the module look generic. Use them when the underlying infrastructure truly has a repeatable, variable structure.

Outputs are a supported integration contract

Outputs allow a module or root state to expose intentional integration points:

output "vpc_id" {
  description = "ID of the VPC created by this module."
  value       = aws_vpc.this.id
}

output "private_subnet_ids" {
  description = "Private subnet IDs keyed by Availability Zone."
  value       = {
    for az, subnet in aws_subnet.private : az => subnet.id
  }
}

output "vpc_flow_logs_enabled" {
  description = "Whether VPC Flow Logs are enabled."
  value       = var.enable_vpc_flow_logs
}

Output enough information for legitimate consumers, but avoid treating internal resource attributes as an undocumented public interface. For most internal modules, named, purpose-specific outputs are easier to evolve safely than dumping full provider resource objects.

When one state needs values from another, expose narrow root-level outputs such as vpc_id or private_subnet_ids. Then choose a controlled sharing mechanism, such as an approved remote-state-output integration or an AWS provider data source. Do not let another root configuration depend on arbitrary internal addresses inside a different state.

Also remember: marking an output as sensitive hides it in normal CLI display, but it does not remove the value from Terraform state. Secrets should be retrieved from a managed secret store at runtime rather than passed around as plain Terraform outputs.


4. Define ownership along with code boundaries

A module is not complete just because it has main.tf, variables.tf, and outputs.tf. It needs an operating model.

For each module, document:

  • Purpose and non-goals: what it provisions, and what it deliberately does not.
  • Owner: the team accountable for its roadmap, defects, security posture, and release decisions.
  • Consumers: which teams are expected to use it.
  • Input and output contract: required inputs, safe defaults, validation rules, and supported outputs.
  • Versioning and upgrade guidance: release notes, compatibility expectations, and migration steps for breaking releases.
  • Examples: minimal valid examples that represent the intended usage patterns.

For an exchange platform, ownership can be divided cleanly:

CapabilityModule ownerState-change authority
VPC, egress, routes, endpointsNetwork/platformNetwork platform pipeline
IAM baseline and privileged rolesSecurity/platformSecurity-controlled pipeline
ECS service platform modulePlatform engineeringPlatform team releases module; application team adopts approved versions
Service-specific production rootProduct application team with platform guardrailsService pipeline with scoped role and approval
RDS module and data stateDatabase/platformDatabase engineering or controlled production process

This model permits contribution without granting unrestricted mutation rights. An application team can submit a pull request to improve a network module, but the network module owner reviews, tests, versions, and releases it. The application team then upgrades the module version explicitly in its own root configuration.

That distinction is valuable in an interview: repository write access, Terraform state access, and AWS mutation privileges should not be assumed to be the same permission.

A concise architecture-defense answer could sound like this:

“I separate reusable Terraform modules from live root configurations. Module boundaries follow cohesion, privilege, and volatility: network and IAM are long-lived, high-privilege platform capabilities, while ECS service roots change at application delivery cadence. Production and non-production use separate AWS accounts, separate backend keys, and scoped deployment roles. Every root pins its Terraform, provider, and module versions, and commits the provider lock file. Modules expose typed, validated inputs and intentional outputs; each module and state has an accountable owner, documented consumers, and controlled release process.”


Key takeaways

  • Design module boundaries around what changes together, who can change it, how often it changes, and acceptable blast radius.
  • Treat a Terraform state file as an operational and ownership boundary, not simply a place Terraform stores data.
  • Separate reusable, versioned module code from environment-specific root configurations.
  • Use explicit Terraform, provider, and module versions; commit .terraform.lock.hcl; make upgrades deliberate.
  • Treat module variables and outputs as stable APIs: typed, validated, documented, and intentionally limited.
  • Isolate production through separate accounts, scoped roles, and independent state backends or state keys.
  • Assign a real owner to every module and state boundary.

Next, you will move from structure to change safety: reviewing a Terraform plan for destructive replacement, dependency, drift, remote-state, locking, privilege, and sensitive-data risks before approving a production change.

Can't find a good explanation? Sign up and we'll make it for you

Sign up