Core Architecture

Enterprise Repository & Module Layout

How to lay out a Terraform codebase for a team: reusable child modules kept apart from the root deployments that use them, state split into layers, and versions pinned so plans don't change by surprise.

Module: 01 of 11 Level: Core Architecture Time: 9 min read Updated:
All modules in this track (11)

In this module, you'll learn to:

  • Keep reusable child modules apart from the root modules that deploy them
  • Split each environment into numbered state layers, and explain what that protects
  • Pin versions exactly in production roots and as ranges in child modules

Before you start: you should be comfortable writing basic Terraform: resources, variables, terraform plan, and terraform apply. This is the first module of the track.

Principle

The two types of Terraform modules

Mixing up a reusable child module and a root deployment module is one of the most common reasons a Terraform codebase becomes hard to maintain.

Keep the two apart:

  • Child modules (the building blocks): reusable blueprints driven by inputs. They don't declare a backend, hardcode account IDs, or mention a specific environment. They're versioned and released like software libraries.
  • Root modules (the deployments): the real environments. They set the S3 state backend and the provider (account, region, credentials), call the child modules, and pass in environment-specific values from .tfvars.
Going further. As a codebase grows, child modules split again: base modules that build one component, and composition modules that join them with IAM and networking. Part 2 of this track, starting at Module 07, takes that standard from design to production.
Structure

The canonical directory layout

Organize the repository into clear layers of responsibility:

terraform-root/
├── modules/                         # Reusable child modules (pure blueprints, versioned)
│   ├── vpc/
│   │   ├── main.tf                  # VPC, subnets, route tables, flow logs
│   │   ├── variables.tf             # Inputs with validation & type constraints
│   │   ├── outputs.tf               # Exports VPC ID, subnet IDs, CIDR blocks
│   │   ├── versions.tf              # Min provider/core constraints (~> 5.0)
│   │   └── README.md                # Generated documentation with inputs/outputs
│   ├── aurora-postgresql/
│   └── eks-cluster/
│
└── environments/                    # Root execution environments (where state lives)
    ├── prod/
    │   ├── 00-bootstrap/            # S3 state bucket and KMS keys
    │   │   ├── backend.tf           # (Local backend during first run, then migrated)
    │   │   └── main.tf
    │   ├── 01-networking/           # Foundation: VPC, Transit Gateway, Route 53
    │   │   ├── backend.tf           # S3 backend with native locking (use_lockfile)
    │   │   ├── main.tf              # Calls modules/vpc with prod parameters
    │   │   ├── providers.tf         # Provider with exact version pin
    │   │   ├── terraform.tfvars     # Prod CIDRs, tags, AZs
    │   │   ├── variables.tf
    │   │   └── outputs.tf           # Writes IDs to AWS SSM Parameter Store
    │   ├── 02-security/             # Organization IAM roles, GuardDuty, Security Hub
    │   │   ├── backend.tf
    │   │   └── main.tf
    │   ├── 03-data/                 # Aurora DB, DynamoDB tables, S3 lakes, EFS
    │   │   ├── backend.tf
    │   │   └── main.tf
    │   └── 04-compute/              # EKS clusters, Node Groups, ALB, ECS services
    │       ├── backend.tf
    │       └── main.tf
    │
    └── nonprod/                     # Parity with prod; identical 5-layer topology
        ├── 00-bootstrap/
        ├── 01-networking/
        ├── 02-security/
        ├── 03-data/
        └── 04-compute/

The 5-Layer Enterprise Slicing Standard

Why slice environments into numbered layers instead of one monolithic deployment?

Layer Responsibility & Components Volatility Blast Radius & Team Ownership
00-bootstrap S3 state bucket and KMS keys Once (Creation only) Highest (Platform Admin / Security Lead
01-networking VPCs, Subnets, Transit Gateway, Route 53 Resolver Quarterly (Slow moving) High) Network & Foundation Platform Team
02-security Base IAM roles, GuardDuty, Security Hub, KMS keys Monthly (Compliance) High (SecOps & Platform Team
03-data Aurora PostgreSQL, DynamoDB, S3 lakes, ElastiCache Monthly (Planned maintenance) Extreme) Database & Platform Storage Team
04-compute EKS clusters, node groups, ALBs, ECS tasks, Lambdas Daily / Weekly (Fast moving) Contained: Application & DevOps Teams
What layering gives you
  • Separate state and locks: running terraform apply in 04-compute locks only the compute state. The database and networking states aren't touched, so a compute change can't accidentally modify them.
  • Separate permissions: in CI/CD (for example GitHub Actions with AWS OIDC), application teams assume an IAM role scoped to 04-compute only. That role can't change 01-networking or 03-data.
  • Faster plans: each layer holds tens of resources instead of hundreds, so terraform plan finishes much faster.
Key rules for the standard module layout
  • Always include versions.tf in every module: Pin the minimum compatible Terraform CLI and provider versions.
  • Always output resource IDs and ARNs: A module that creates an S3 bucket or IAM role must output both .id and .arn so downstream modules can bind policies without querying data sources.
  • One primary job per module: A module should represent a single logical system boundary (e.g. vpc, aurora-cluster, eks-cluster), not an entire datacenter in one file.
Versioning

Exact pins in roots, ranges in modules

Where you declare a version matters as much as which version you pick:

1. In child modules: allow a range with ~>

The pessimistic constraint operator ~> lets a module accept compatible minor updates, so it works in more places:

# modules/vpc/versions.tf
terraform {
  required_version = ">= 1.5.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.30" # Accepts 5.30.x and 5.99.x, but rejects 6.0.0
    }
  }
}

2. In production roots: pin exact versions with =

A production plan should give the same result every time. If a provider release changes behaviour overnight, your pipeline shouldn't pick it up by surprise:

# environments/prod/01-networking/versions.tf
terraform {
  required_version = "= 1.11.4" # 1.11+: native S3 state locking is generally available

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 5.48.0" # Strictly pinned for reproducible plans
    }
  }
}
Failure Modes

Anti-patterns to avoid

Anti-Pattern 1: The "Monolithic State Monster"

Putting VPC networking, Aurora clusters, IAM roles, and EKS deployments in a single main.tf file.

  • Why it fails: one typo in an IAM policy can put the database in the same plan, and terraform plan gets slow across hundreds of resources.
  • Fix: split state into separate layers (01-networking, 02-security, 03-data, 04-compute).

Anti-Pattern 2: Hardcoding Environment Logic Inside Modules

Writing if var.env == "prod" inside a reusable module.

  • Why it fails: the module now only works for the environments it knows about, and every new environment means editing the module.
  • Fix: pass the setting in as a variable instead (for example retention_in_days = var.log_retention_days).

Anti-Pattern 3: Unpinned Modules in Git

Referencing modules using source = "git::https://github.com/.../my-module.git" without a tag or commit ref.

  • Why it fails: Any push to main in the module repo immediately changes future plans in production.
  • Fix: always pin to a release tag such as ?ref=v1.4.2, or a commit SHA.
Recap

Key terms

  • Child module: a reusable blueprint driven by inputs. No backend, no account IDs, no environment names.
  • Root module: a real deployment. It sets the backend and provider, calls child modules, and passes in each environment's values.
  • State layer: a numbered root (00-bootstrap to 04-compute) with its own state, its own lock, and its own CI role.
  • ~> constraint: accepts compatible updates within a major version. ~> 5.30 allows 5.30 and later 5.x, but not 6.0.
  • Exact pin (=): used in production roots, so a new provider release can't change a plan by surprise.
Check yourself

Pop quiz

Five questions: three on the ideas in this module, and two scenarios where you apply them. The order changes every time you take it, and 4 out of 5 passes.

5 questions. Decide on your answer to each, then check it.

  1. Concept

    Which of these belongs in a root module, not in a reusable child module?

    1. The S3 backend configuration
    2. A variable for the VPC CIDR
    3. An output for the bucket ARN
    4. A versions.tf with a provider range
    Show answer

    The S3 backend configuration

    Root modules decide where state lives and which account and region to use. Child modules only take inputs and return outputs.

  2. Concept

    Why does an application team's CI role get access to 04-compute only?

    1. Compute plans are faster
    2. A mistake or a compromised pipeline can't change the networking or data layers
    3. Terraform allows only one role per state file
    4. 04-compute doesn't use a backend
    Show answer

    A mistake or a compromised pipeline can't change the networking or data layers

    Each layer has its own state and its own role, so a compute change can't reach the network or the database.

  3. Concept

    A child module declares version = "~> 5.30" for the AWS provider. Which version does it reject?

    1. 5.30.2
    2. 5.48.0
    3. 6.0.0
    4. 5.99.0
    Show answer

    6.0.0

    ~> 5.30 accepts 5.30 and any later 5.x release, but not the next major version.

  4. Scenario

    A teammate adds var.env == "prod" ? 365 : 30 inside the shared vpc module to keep flow logs longer in production. What do you suggest instead?

    1. Keep it; it's the simplest change
    2. A flow_log_retention_days input, set per environment in .tfvars
    3. A separate vpc-prod copy of the module
    4. The longest retention for every environment
    Show answer

    A flow_log_retention_days input, set per environment in .tfvars

    A module that checks environment names only works for the environments it knows. An input keeps it reusable, and each root decides its own value.

  5. Scenario

    Production plans suddenly show changes nobody made, right after someone pushed to a module repository's main branch. The root uses source = "git::https://.../vpc.git". What's the fix?

    1. Pin the module source to a release tag such as ?ref=v1.4.2, or a commit SHA
    2. Run terraform init -upgrade before every plan
    3. Pin the AWS provider version
    4. Copy the module into the root
    Show answer

    Pin the module source to a release tag such as ?ref=v1.4.2, or a commit SHA

    Without a ref, every push to the module repository changes what production plans. Pin it, and upgrade on purpose.

Questions about this learning path?

I'm happy to go into more detail on any of this: module design trade-offs, moving state safely, testing modules, or running a security gate in CI/CD.

anupojuprudhvi@gmail.com