[Demo Webinar] Crafting self-service infra with Spacelift Blueprints

➡️ Register Now

General

Common Patterns of IaC Architecture: Terraform, OpenTofu & Terragrunt

Patterns of infrastructure as code

Terraform and OpenTofu are the well-known standards for infrastructure as code (IaC). Terragrunt builds on top of them and helps you keep configurations DRY, manage remote state, and orchestrate complex environments.

As teams grow, the hard part isn’t writing individual .tf/.tfvars files – it’s designing an architecture for your IaC:

  • How should your repos be structured?
  • Where do you draw the boundaries between components and environments?
  • How do you keep configuration DRY without creating a tangled mess?

In this article, we’ll walk through common architecture patterns for IaC based on Terraform, OpenTofu, and Terragrunt. We’ll stay tool-agnostic where possible, and use concrete examples to show how these patterns look in practice – and how platforms like Spacelift make them easier to implement at scale.

Although the examples presented in this article use Terraform, OpenTofu, and Terragrunt, the main concepts apply to other popular IaC tools and platforms like Pulumi, AWS CDK, CloudFormation, Terraform Cloud, Atlantis, Spacelift, and Terraform Cloud alternatives.

Pattern 1: A sensible project layout

A well-structured project layout makes it clear what is reusable and what is specific to a particular environment. One widely used approach is to separate “building blocks” from “live infrastructure”:

The modules/ directory holds generic, reusable modules. They define patterns such as “an application service behind a load balancer”, “a standard VPC” or “a managed database” that you can stamp out in multiple places.

The live/ directory reflects reality: concrete environments like dev, staging and prod, each broken down into components like app, database and networking. This structure makes a few things much easier:

  • You can review a change to live/prod/app and know exactly which part of production it affects.
  • You can keep environment-specific decisions (instance sizes, replica counts, feature flags) in one obvious place.
  • You can promote changes from dev to staging to prod by applying the same modules with different inputs.

If you use Terragrunt, the directories under live/ typically contain terragrunt.hcl files instead of raw Terraform/OpenTofu configuration. The top-level structure stays the same – you just move more wiring into Terragrunt. 

If you prefer to keep your modules and environments in separate repositories, the same pattern can look like this:

folder structure

Check out our Terragrunt vs. Terraform comparison.

On Spacelift, this layout maps naturally to stacks. Each component directory under live/ (for example live/prod/app) becomes a stack pointing at that path. Each stack can use Terraform, OpenTofu or Terragrunt as the workflow tool, but benefits from the same integrations and policies.

Pattern 2: Separating development from deployment

The infrastructure code has two very different phases: development and deployment.

Development is about making changes to IaC: editing modules, refactoring layouts, introducing new resources, adjusting variables. This is collaborative work, done in branches and reviewed in pull requests.

Deployment is about taking those changes and applying them to real cloud environments. This is where you affect uptime, cost and security, so you want clear guardrails and an audit trail.

Separating development from deployment

A simple, Git-driven workflow keeps these phases separate:

  1. An engineer creates a branch and modifies code under modules/ or live/.
  2. They open a pull request. A plan runs automatically (using Terraform, OpenTofu or Terragrunt) and posts the result back to the PR.
  3. Reviewers check both the code diff and the plan output.
  4. Once the change is approved, it is merged into the main branch.
  5. A second, trusted process picks up the commit from the main branch and runs the corresponding apply.

The important detail is that applies are not triggered manually from a laptop with long-lived credentials. They happen in a consistent environment and are always tied to a specific commit.

Spacelift implements this pattern out of the box. Each stack is wired to a repo, branch and directory. Pull requests create proposed runs that show plans and policy decisions inside your Git provider. Merges create tracked runs that apply changes. 

You can also add approval gates, policies and notifications to build the exact deployment flow you want, without breaking the separation between development and deployment.

Pattern 3: Slicing your architecture into components

When you start with IaC it is common to have “one big thing”: a single state file or Terragrunt stack creating everything: VPCs, databases, queues, ECS services, Lambda functions and more. This works for early prototypes, but becomes a liability as the system grows.

A more scalable approach is to slice the infrastructure into components and give each component its own state and execution pipeline. A simple way to do this is along architectural lines:

  • Networking: VPCs, subnets, route tables, gateways, shared security groups.
  • Data: databases, caches, message queues, storage buckets.
  • Applications: container services, serverless functions, autoscaling groups, APIs.

In the earlier example, live/prod/networking, live/prod/database and live/prod/app are each independent. They have separate state files and can be planned and applied separately.

This brings a few direct benefits. Plans are faster because they cover fewer resources. Failures and drift are easier to localise. Different teams can own different components without stepping on each other’s toes. 

Most importantly, the blast radius of changes shrinks: tweaking application capacity does not risk accidentally modifying core networking or data infrastructure.

Terragrunt makes these components explicit by treating each directory as a stack with its own terragrunt.hcl. You can express dependencies between stacks when needed, for example, “app depends on networking and database”. Terragrunt then takes care of passing outputs and ordering applies.

Spacelift builds on this component view. Each directory becomes a stack, and stack dependencies let you model relationships such as “prod networking must be healthy before prod database, which must be healthy before prod app”. You can visualise the dependency graph, apply changes in the correct order, and use policies to guard sensitive components more strictly.

Pattern 4: Staying DRY with modules, Terragrunt and Spacelift

Keeping infrastructure code DRY (Don’t Repeat Yourself) is about more than saving keystrokes. It reduces configuration drift, makes refactors safer and keeps behaviour predictable across environments.

At the Terraform/OpenTofu layer, modules are the main tool for reuse. A module encapsulates a pattern once and exposes a parameterised interface.

A very simple example:

# modules/app/main.tf
variable "name" {}
variable "desired_count" {}
variable "cluster_arn" {}
variable "task_definition_arn" {}

resource "aws_ecs_service" "this" {
  name            = var.name
  desired_count   = var.desired_count
  launch_type     = "FARGATE"
  cluster         = var.cluster_arn
  task_definition = var.task_definition_arn
}

In production, you can consume this module like so:

# live/prod/app/main.tf
module "app" {
  source = "../../../modules/app"

  name                = "my-api-prod"
  desired_count       = 4
  cluster_arn         = var.cluster_arn
  task_definition_arn = var.task_definition_arn
}

If you later add logging, metrics or tags to the module, you do it once and all environments benefit when they redeploy.

Configuration inheritance is a feature supported by Terragrunt. Instead of repeating backend settings, provider blocks or common variables in every stack, you can define them once and have child configurations inherit them.

Configuration Inheritance

A typical hierarchy might look like this:

# live/terragrunt.hcl
remote_state {
  backend = "s3"
  config = {
    bucket = "my-company-terraform-state"
    region = "eu-central-1"
  }
}

inputs = {
  company_name = "acme"
}

# live/prod/terragrunt.hcl
include {
  path = find_in_parent_folders()
}

inputs = {
  environment = "prod"
}

# live/prod/app/terragrunt.hcl
include {
  path = find_in_parent_folders()
}

terraform {
  source = "../../../modules/app"
}

Shared decisions about state and defaults sit near the top. Environment-specific settings live one level down. Component-level files mostly say “use this module for this environment”.

On Spacelift, you can push DRY a little further using Contexts and Policies. Contexts hold shared environment variables and files – for example, credentials, common tags or organisation-wide settings – that you can attach to many stacks at once. 

Policies, written in Rego, let you centralise rules like “all S3 buckets must have encryption enabled” or “production applies require at least two approvals”. This keeps compliance and governance in one place while the Terraform/OpenTofu/Terragrunt code remains focused on infrastructure.

You can also use Spacelift to mix and match Terraform, Pulumi, and CloudFormation Stacks and have them talk to one another.

Pattern 5: Mono-repo vs multi-repo (polyrepo)

At some point, you will have to decide how many Git repositories your IaC should live in. The classic tension is between a single mono-repo and a multi-repo (polyrepo) approach.

Here is a quick comparison:

Aspect Mono-repo Multi-repo (polyrepo)
Number of repos One Many
Visibility Everything in one place Each repo shows only part of the picture
Cross-component refactors Simple: one branch, one PR Require coordination across repos
Tooling & CI configuration Centralised and consistent Tailored per repo but duplicated in places
Access control Coarse unless you add extra mechanisms Natural isolation at repo level
Team autonomy Teams share workflows and release cadence Teams can choose their own branching/release models
Getting started Very easy Higher initial overhead

A mono-repo works well when you are still discovering patterns, have a small platform team, or want a single source of truth. It makes it easy to change a module and update all its usages in one go. It also simplifies onboarding: new team members clone one repo and see everything.

As teams and systems grow, multi-repo layouts can become more attractive. You might separate shared modules, core platform infrastructure, and application-level IaC into different repositories, or give each product team its own infrastructure repo. This allows teams to move at their own speed, adjust CI pipelines to their needs and isolate areas with stricter compliance requirements.

Spacelift does not force you into either model. Stacks can point to different repositories, branches, and directories. You can keep a central mono-repo for core infrastructure, let teams own separate repos for their applications, and still manage everything through a single Spacelift organisation with shared policies and visibility.

As a rule of thumb: start with a mono-repo until you feel real pain (permissions, team autonomy, CI complexity). When that happens, split along natural boundaries, such as ownership, domains, or environments. The patterns from earlier modules, such as live code, component slicing, and DRY configuration, apply equally on both sides.

Putting it all together with Spacelift

The patterns described so far are tool-agnostic ideas for managing complexity in Terraform, OpenTofu, and Terragrunt projects. Spacelift’s role is to operationalise those ideas.

Each directory under live/ becomes a stack. Stacks are wired to your Git repositories and branches, understand whether they should use Terraform, OpenTofu or Terragrunt, and know where to find configuration. Changes in Git create runs that show plans, policy decisions and apply logs. 

Stack dependencies capture relationships between components, ensuring they run in the correct order.

Cross-cutting concerns are handled by Contexts, which share configuration safely, and Policies, which enforce rules on plans, applies, triggers and access. 

Drift detection helps you spot when real infrastructure has changed out of band, and the audit log makes it clear who changed what and when.

The end result is that the architecture you design in your repositories – modules, live environments, components, and repos – is reflected one-to-one in Spacelift. You get a clear mental model, reproducible workflow,s and a consistent way of managing infrastructure across tools and teams.

Key points

In this article, we reviewed several patterns of IaC architecture in a technology-agnostic way. All of these patterns can be easily implemented in Spacelift, as the product works natively with all Terraform, OpenTofu and Terragrunt.

Your workflows can be extended with other features like policies, and third-party integrations and you even have the possibility to bring your own runner image. Spacelift’s flexibility extends to other popular IaC technologies like Pulumi, CloudFormation, Ansible, and Kubernetes, where the same patterns keep appearing in slight variations.

We rooted the principles of IaC architecture design in software engineering concepts like inheritance, DRY, project layout, parameterization, and so on. After all, infrastructure as code is coding infrastructure, and recognizing common patterns is the recommended way to learn and apply IaC.

You can try Spacelift for free by creating a trial account or booking a demo with one of our engineers.

Solve your infrastructure challenges

Spacelift is an alternative to using homegrown solutions on top of a generic CI. It helps overcome common state management issues and adds several must-have capabilities for infrastructure management.

Learn more

The Practitioner’s Guide to Scaling Infrastructure as Code

Transform your IaC management to scale

securely, efficiently, and productively

into the future.

ebook global banner
Share your data and download the guide