Why structure Terraform into modules When a cloud infrastructure grows beyond the prototype stage, Terraform code quickly becomes a heterogeneous set of declarations that repeat from one environment to another. Each developer applies their own conventions, variables are poorly documented, and adding a new resource or duplicating an architecture costs days of manual work and adjustments. Terraform modules solve this problem by encapsulating coherent groups of resources into reusable, testable, and versioned units. Instead of copy-pasting configuration blocks or writing the same interconnection logic between a VPC, subnets, and security groups multiple times, you write it once, then instantiate it as many times as needed by passing different parameters. This approach drastically reduces the surface for error, speeds up the deployment of new environments, and facilitates centralized maintenance. For a mid-market company or a scale-up operating on AWS, moving to a modular Terraform architecture is often the moment when the infrastructure stops being an artisanal accumulation of clickops and becomes a real team codebase, with traceability, review, and continuity. ## Terraform module architecture and naming conventions A Terraform module is simply a folder containing at minimum three files: main.tf (resource declaration), variables.tf (the input parameters), and outputs.tf (what the module exposes to the outside). The convention recommended by HashiCorp and widely adopted in the industry places the modules in a modules/ folder at the root of the project, each subfolder corresponding to a logical domain: modules/networking for everything related to VPC, subnets, and routing, modules/security for IAM and security groups, modules/compute for EC2, autoscaling, and so on. Each module must have a short, meaningful, lowercase name with underscores (e.g. alb_application, rds_postgresql, iam_role_lambda). Inside a module, the input variables must be explicitly typed and documented with clear descriptions, never left to type inference or without context. The outputs must return exactly what another module or the root configuration would need to know (IDs, ARNs, endpoints) without exposing redundant information. For groups of modules that work together, a consistent Terraform resource naming convention within a project helps enormously: if you prefix your AWS resources with the module name and the environment context (e.g. networking_prod_vpc, security_prod_alb_sg), you avoid name collisions and make the configurations easy to browse and debug in production. The version paths must also be documented: a stable module is used with a specific version (e.g. source = "./modules/networking?ref=v1.2.0" locally, or source = "git::https://git.mycompany.com//terraform-modules.git//networking?ref=v1.2.0" if you host your modules in a private Git repository). ## Variables, outputs, and interaction between modules A module's variables.tf must be written so that any other engineer can use the module without reading the code of main.tf. Each variable must have a declared type (string, number, list, map, object, or a combination), a default value if it is not mandatory, and a description detailed enough to indicate the expected format, accepted examples, and any constraints. For example, instead of simply writing "variable instance_type", write a variable with the description "EC2 instance type (e.g. t3.micro, t3.small); check the supported availability zones in the current environment" and type = string. The outputs.tf must contain only what will be consumed by another module or by the root configuration. If you create an EC2 instance, expose its ID and its private IP address, but not its internal configuration in detail. A good practice is to version the outputs: if an output is no longer used or changes meaning, create a new output rather than rewriting the old one, so as not to break downstream dependencies. When a module calls another module (module composition), the outputs of the first become the input variables of the second. Terraform automatically manages this execution order via its dependency graph, but you must explicitly document these dependencies. If your compute module needs a security group, make sure that the module providing it (e.g. modules/security) is called first and that you pass its outputs back to the compute module as input variables. A good practice to avoid overly tight coupling is to use map or object type variables to group related parameters, rather than exposing twenty individual variables: a module can then accept a single structure that decomposes into internal parameters, making the root configuration much more readable. ## Governance, versioning, and reusability at scale Once your modules are stabilized, the real governance begins: how to ensure that all projects and environments use them correctly, that versions are controlled, and that updates break nothing in production. The first step is to host your modules in a version control system (Git, with a private repository on GitLab, GitHub, or Gitea depending on your stack). Each module must have a clear changelog, semantic versioning (v1.0.0, v1.1.0, v2.0.0), and Git tags. A module at version v1.2.3 must remain stable; if you make a breaking change, that is v2.0.0. This means that any other project can point to a fixed version and only be affected if it explicitly chooses to upgrade. In Terraform, use either the local path (source = "./modules/xxx") for a monorepo where all the modules and their consumers are in the same repository, or a Git path (source = "git::https://...terraform-modules.git//networking?ref=v1.2.0") for a repository dedicated to the modules. Monorepos are easier to maintain at first, but become impractical if several teams or projects must evolve at different paces; a centralized module repository offers more flexibility. For governance in a Git module repository, use pull requests: each change to a module must go through a peer review, with a check that the Terraform tests pass (terraform validate, terraform plan) and that the documentation is up to date. Use terraform fmt -recursive to enforce consistent formatting and a linter (e.g. tflint) to detect errors or patterns that do not conform to your policy (e.g. absence of AWS tags, VPCs not encrypted by default). Finally, document each module with a clear README.md: what the module does, what the AWS prerequisites are (e.g. a VPC already created), how to use it with concrete examples, and what the limits or external dependencies are. This documentation must be maintainable, with a script that automatically regenerates the inputs/outputs sections from the variables.tf and outputs.tf files to avoid inconsistencies. ## Testing and validating Terraform modules A Terraform module without tests is a fragile module. Like any code, it must be validated before being put into production. The three levels of validation for Terraform are: first, terraform validate, which checks the syntax and internal consistency of the code (variable types, references between resources, structure); then, terraform plan, which computes the real divergence between the local state and the AWS state, thereby flagging logic or IAM permission errors without applying any change; finally, integration tests that actually run terraform apply on a test environment, verify that the created resources work correctly, then destroy them. For modules, frameworks such as Terratest (in Go) make it possible to write reproducible integration tests: you describe a scenario (e.g. create an RDS cluster via the module, verify that it is accessible, delete it), then Terratest orchestrated in your CI/CD guarantees that this scenario passes at each version. A good practice is to create examples/ files in the module folder, containing complete and realistic root configurations that use the module with different combinations of inputs, to document the use cases and serve as a basis for integration tests. For each example, you run terraform apply, then verify the expected outputs, then terraform destroy to clean up. In an IT or DevOps team at a scale-up, this systematic approach to testing transforms module maintenance from a painful task (upgrade a module and wait for the production bugs) into a smooth task where you know that a version has been validated on all the documented use cases. It also frees up time to focus on architecture evolution rather than reactive debugging. ## Real use cases: structuring your modules for AWS In practice, for a mid-market company or a scale-up on AWS, here is how to structure your modules to cover the most common use cases. Start with a networking module that encapsulates the VPC, the public and private subnets, the route tables, and the NAT gateway: it exposes the subnet IDs and the VPC ID that you will pass to the other modules. Then, a security module that manages the security groups (inbound/outbound rules) for different workloads (web, database, cache): rather than creating one security group per instance, you create one per functional role and instantiate it several times with configurable rules. A compute module can encapsulate EC2 + user data, or ALB + target groups, or Lambda + IAM role and environment variables, or an ECS cluster + task definitions, depending on your context. A database module can manage a multi-AZ RDS instance with a backup policy, CloudWatch monitoring, and secure access via a security group. A storage module for S3 buckets with versioning, encryption, and access policies. Each of these modules must be general enough to adapt to several use cases (e.g. the database module supports PostgreSQL, MySQL, MariaDB via an engine_type variable), but specific enough that someone using it does not have to know the internal AWS details. If your network module automatically creates a NAT gateway with an Elastic IP, you make life easier for 90 percent of your users; if you force them to configure it themselves, 50 percent will forget it or misconfigure it. Finally, a root configuration at the top of the project or repository calls these modules in the correct order, passes the outputs from one to another, and documents how to provision a complete new environment in three Terraform commands. This pattern transforms the onboarding of a new engineer: instead of understanding 500 lines of implicit Terraform configuration, they clearly see which modules are used, with which parameters, and can extrapolate the architecture logic. ## Integrating modules into your CI/CD pipelines Once your modules are stabilized and tested, they must integrate into your existing deployment pipelines. If you use GitHub Actions, GitLab CI, or Jenkins, each deployment step must call terraform plan and terraform apply with the right variables and the right backend (Terraform state centralized in S3 with DynamoDB locks). When a module change arrives in main or release, the pipeline must re-run the examples and the integration tests with the new variables and the new modules. This means that your Terraform backend (by default an S3 bucket) must be shared or at least organized by environment (dev, staging, prod) and by project, so that each team is not blocked by another's locks. For reusable modules, a good practice is to use Terraform workspaces or environment variables passed to the module (e.g. var.environment = "prod" vs "staging") so that the same module code deploys correctly isolated resources for each environment. Finally, if you have several teams or projects, consider an orchestration layer (e.g. Terraform Cloud or Terraform Enterprise from HashiCorp, or an in-house wrapper) that adds governance controls: mandatory approvals before apply in prod, audits of who applied what and when, and estimated costs for each terraform plan. This transforms your infrastructure from artisanal code to industrialized team code, where every change is traceable, reversible, and compliant with company policies.