· Documentation  Â· 5 min read

Scaling Terraform - A GitOps Prelude

My decade of scaling GitOps workflows with Terraform

My decade of scaling GitOps workflows with Terraform

Introduction

My journey with Infrastructure as Code (IaC) started with server configuration using Puppet in 2012. I used Vagrant to test modules, Packer to build AMIs, and a mix of CloudFormation and the AWS Python SDK to deploy infrastructure. This was a major step up from the artisanal operations of pet management—but it still meant tedious config management and effectively kept software engineers away from the dungeons of “the ops people.”

I began developing an approach to eliminate manual configuration and reduce complexity. With the early release of Terraform in 2014, it became clear this tool could lower the barrier to entry, minimize the blast radius of changes, and enable a more dynamic way to build and manage infrastructure. I introduced the first iteration in a SysAdvent article in 2016.

Terraform has matured since then, and the core principles of this approach have only gotten stronger. Yet people still argue over deployment strategies—usually because they’re trying to sell you something. I am not.

Since then, I’ve implemented this approach across multiple organizations—large and small—with lasting success. In every case, teams were able to maintain and evolve it long after I was gone. That kind of staying power is rare in infrastructure work.

In this post, I’ll cover:

The HCL formatting throughout follows my Terraform style guide, shaped by lessons learned and feedback from real-world rollouts.

What Are We Solving?

Here is a list of benifits and problems we’re solving with this approach:

  • Terraform version mismatches
  • Misconfigured enviroments
  • Overhead of Terraform setup (init, upgrading modules, backend configuration)
  • Free: just be willing to run a pretty clean bash script
  • Limit the amount of things you need to manage in order to ship

What is GitOps?

GitOps is a modern DevOps practice that uses Git as the single source of truth for managing infrastructure and application configurations. It brings the principles of version control, collaboration, compliance, and automation to infrastructure operations, making it easier to manage complex systems reliably and at scale.

I. Foundational Hierarchy

At the highest level, we maintain a dedicated GitHub repository to manage infrastructure. This is where Terraform runs and where we define our root modules—the entry points for applying changes.


infrastructure/aws/curiqa-dev/us-east-1/dev-vpc-use1/dev/curiqa-api
Example folder structure of a dev environment

The directory structure mirrors the AWS resource hierarchy. Top-level folders manage account-wide resources, and each nested directory scopes resources further—by region, VPC, environment, and finally, service. Each directory contains a root module that calls the relevant, versioned child module, providing consistency, isolation, and traceability at every layer.



Overview of hierarchy in multiple regions
Overview of hierarchy in multiple regions


Examples of resources provisioned at each level
Examples of resources provisioned at each level

II. Root Modules

A core tenet of this approach is consistency. Every account follows the same layout, and each root module shares a standardized pattern—making it easier to onboard new team members, enforce standards, and scale infrastructure reliably.

Each root module consists of Terraform configuration files (backend.tf, provider.tf, main.tf, and versions.tf), variable definition file (variables.tf) and output values file (outputs.tf). The main.tf file is responsible for calling and configuring the relevant child module(s).

The final piece is terraform.sh—a Bash wrapper that handles several configuration steps and serves as the backbone of this approach. It’s shared across all root modules via a symlink to infrastructure/utilities/terraform.sh.

Terraform Wrapper

The consistent folder structure and module composition provide a few key guarantees. The terraform.sh wrapper builds on this by using the directory path to populate Terraform input variables—applying a convention-over-configuration approach.

The script accepts standard Terraform subcommands (apply, plan, destroy, import, etc.) and performs the following during root module setup:

  • Populates input variables based on folder names in the path
  • Creates a provider.tf file and sets default tags
  • Creates a backend.tf file and configures the remote state location
  • Creates a versions.tf file with Terraform and AWS provider versions
  • Installs the specified version of Terraform using tfenv
  • Sets and creates the TF_PLUGIN_CACHE_DIR
  • Initializes the root module and upgrades child modules

Example Root Account Module

Applying Root Account Module & Remote State



Account resources are created after running ./terraform.sh apply and remote state is recorded in S3
Account resources are created after running ./terraform.sh apply and remote state is recorded in S3


Terraform creates resources defined in child modules, and any output values declared within those modules are available only within the root module’s context. These outputs are not directly accessible via remote state unless they are explicitly exposed by the root module.

In this setup, the root module’s outputs.tf file exports the entire child module’s outputs as a single object. This approach lets us define output values once in the child module and makes them available to other modules through terraform_remote_state.

# Child module outputs.tf file
output "admin_iam_role_arn" {
  value = aws_iam_role.admin_iam_role.arn
}

output "domain_name" {
  value = var.domain_name
}
...
# Root module outputs.tf file
output "account" {
  value = module.account
}

This sets the stage for discussing child modules—and how dynamic variable naming based on folder structure becomes a powerful pattern for scaling and reuse.



And now for the magic
And now for the magic


III. Child Modules

The child modules contain configuration blocks used to create infrastructure objects. Let’s take a look at a very basic region module.


acm.tf
data.tf
outputs.tf
route53.tf
variables.tf
Files found in a basic region module

Building of the previous sections we have:

  1. An infrastructure repo containing an aws provider directory with a resource hierarchy that mirrors the cloud architecture
  2. Root modules that reference versioned child modules and store remote state in S3 using a consistent key structure
  3. A terraform.sh wrapper that automates configuration and bootstraps each root module based on directory context

Child Module Structure

We deploy our

While root modules don’t directly reference those above them, the modules they call can access outputs from parent modules through remote state.

Back to Blog