Three-Layer Architecture for Your IaC with Terraform and GitHub

Published by Armel GANNE
Category : Azure / Terraform
05/12/2025

When deploying Infrastructure as Code with Terraform and GitHub at an organizational scale, you often fall into one of two traps: either giant “shared skeletons” that block everyone, or a wild west where each team reinvents the wheel in its own repository. Having experienced this tension across several contexts, we experimented with a three-tier IaC architecture that drastically simplified our lives.

This architecture is not theoretical. Today, it powers dozens of Azure services deployed through Terraform, with autonomous teams pushing changes several times per day through GitHub Actions—without sacrificing governance or security.
Here’s what we learned.

  

The Three-Tier Framework That Simplified Everything

 

The Problem With “Monolithic” Architectures

We started from a classic situation: a centralized infrastructure where every change required cross-team coordination, aligned deployment cycles, and heavy governance. Product teams waited weeks for an environment, while the platform team drowned under requests.

The diagram below illustrates the decomposition we eventually designed to solve this problem.

 

3 layers architecture

 

Clear Responsibilities

Here is a quick overview of the responsibilities for each tier:

  • Organization: The infrastructure team deploys and maintains shared services—VNet, DNS, Key Vault, Service Bus. These components have long lifecycles and impact the entire organization.
  • Platform: The DevOps team maintains workflows, Terraform modules, and deployment patterns. These evolve to support product team needs.
  • Products: Each product team uses modules and workflows to deploy their services. They stay autonomous on business logic and release cycles.

 

Empowered but Guarded Autonomy

The major advantage of this structure is that it gives teams a readable perimeter:

  • Enforced by the organization: security standards, naming conventions, network architecture
  • Under their direct control: application technology, deploy frequency, business configuration
  • Influenceable: evolution of standards, adoption of new practices, process improvements

 

Here’s an example for a Function App defined by a Product team:

 

  # Product team using a platform module
  module "function_app" {
  source = "../modules_shared/function_app"  # ← Fourni par la plateforme


  # Product specific configuration
  function_app_name = "fun-tiers-enseigne-client-${var.environment}"
  storage_account_name = "sa-tiers-enseigne-client-${var.environment}"


  # Standards automatically apply by the module
  # → VNet integration, Managed Identity, Key Vault access, monitoring
}

 
 

Why Terraform Fits This Model Perfectly

 

A Single Language Across All Layers

Terraform lets us describe the platform and products using the same language, while keeping separate states. This consistency dramatically improves collaboration.

 

# Organization level : Shared Infrastructure
resource "azurerm_app_service_environment_v3" "ase" {
  name                         = "ase-network-shared-tiers-${var.env}"
  internal_load_balancing_mode = "Web, Publishing"
  # Enterprise configuration with complete network isolation
}

# Product level : Application using the ASE
resource "azurerm_linux_function_app" "app" {
  name                       = "fun-tiers-enseigne-client-${var.environment}"
  app_service_environment_id = data.azurerm_app_service_environment_v3.shared_ase.id
  # The application inherit from network isolation
}

 

State Segmented by Context

Each tier maintains its own Terraform state, for example:

  • Organization: terraform-state-org-tiers-{env}
  • Products: terraform-state-enseigne-client-{env}

 

This separation prevents team conflicts and enables independent lifecycles.

 

Modules as Carriers of Standards

Modules become the vehicle for organizational standards—without adding friction.

 

# Key Vault module with security by design
resource "azurerm_key_vault" "this" {
  name                          = var.keyvaultName
  public_network_access_enabled = false              # ← Default security
  enable_rbac_authorization     = true               # ← RBAC enforced
  # Tags automatiques pour la gouvernance
  tags = merge(var.tags, {
    "cost-center"    = var.cost_center
    "environment"    = var.environment
    "managed-by"     = "terraform"
  })
  lifecycle {
    prevent_destroy = true                           # ← Delete prevention
  }
}
# Private Endpoint
resource "azurerm_private_endpoint" "this" {
  name      = "pe-${azurerm_key_vault.this.name}"
  subnet_id = var.subnet_id
  private_dns_zone_group {
    name                 = "privatelink.vaultcore.azure.net"
    private_dns_zone_ids = [var.private_dns_zone_id]  # ← Automatic DNS
  }
}

 

This ensures easy and consistent adherence to naming conventions, security requirements, and best practices.

By default, modules enforce:

  • Private isolation: public access disabled
  • No secrets: authentication via Managed Identity only
  • Least privilege: minimal RBAC permissions applied
  • Continuous observability: diagnostic settings automatically configured

 

The Temptation of “One More Parameter” — and Golden Paths

Refusing to add “just one more parameter” protects the speed of the entire organization. A specialized module is often more maintainable than a bloated, generic one.

In parallel, we identified recurring scenarios representing ~80% of our projects. For these Golden Paths, we created ready-to-use templates requiring only configuration. This dramatically accelerates delivery.

 

Limitations to Acknowledge

However, there are a few limitations to keep in mind to ensure everything runs smoothly:

  • Poorly designed modules: A poorly designed module affects all its consumers, creating technical debt that spreads across the entire organization. This is why we’ve learned to be conservative with module interfaces and to version every change rigorously.
  • Strict versioning is mandatory:With this in mind, module changes require a well-designed migration strategy. As a result, we use strict semantic versioning and establish overlap support periods, allowing teams to migrate at their own pace without disruption.

 
 

GitHub Actions as the Automation Backbone

 

Same Pipelines, Different Contexts

The objective was bold: every team should use exactly the same workflows, with only inputs and context changing.

 

# Product Workflow : full delegation to the platform workflow
name: Deploy Infrastructure
on:
  workflow_dispatch:
    inputs:
      env:
        required: true
        type: choice
        options: ['dev', 'int', 'prod']
jobs:
  deploy:
    uses: WORKSPACE_GITHUB/flux-common-ci-config/.github/workflows/deploy-terraform.yaml@1.0.0
    with:
      env: ${{ inputs.env }}
      working-directory-path: ${{ vars.TERRAFORM_WORKING_DIRECTORY_PATH }}
      tfstate-file-name: ${{ vars.TERRAFORM_TFSTATE_FILE_NAME }}
      do-apply: ${{ inputs.do-apply }}

 

Reusable, Versioned Workflows

We centralize all workflows in a dedicated repository (flux-common-ci-config), with strict versioning:

 

# Platform Workflow : reusable logic
name: Deploy Terraform
on:
  workflow_call:
    inputs:
      env: { required: true, type: string }
      working-directory-path: { type: string }
      tfstate-file-name: { type: string }
      do-apply: { type: boolean, default: false }
jobs:
  deploy-dev:
    if: inputs.env == 'dev'
    runs-on: [self-hosted, ais, dev]           # ← Isolated runner per environment
    steps:
      - uses: WORKSPACE_GITHUB/flux-common-ci-config/.github/actions/deploy-terraform@1.0.0
        with:
          managed-identity-client-id: ${{ vars.FLUX_ENSEIGNE_MANAGED_IDENTITY_DEPLOYMENT_DEV }}
          storage-account-name: sanetworksharedmaisdev
          tfstate-file-name: ${{ inputs.tfstate-file-name }}

 

OIDC to Eliminate Persistent Secrets

This framework also enforces more secure mechanisms. For example, no more secrets stored in GitHub for Azure connections. Each environment now uses a dedicated Managed Identity:

 

- name: Login with Azure CLI
  run: az login --identity --client-id ${{ inputs.managed-identity-client-id }}
- name: Terraform Init
  run: |
    terraform init \
      -backend-config="storage_account_name=${{ inputs.storage-account-name }}" \
      -backend-config="use_azuread_auth=true"    # ← Entra ID Authentication

 

Built-in Traceability

Each deployment is tracked along with its context:

 

- name: Tag deployment
  run: |
    az tag create --resource-id ${{ steps.terraform.outputs.function_app_id }} --tags \
      "last-deployed-by=${{ github.actor }}" \
      "last-deployed-at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
      "git-commit=${{ github.sha }}" \
      "workflow-run=${{ github.run_id }}"

 

Plan / Apply Separation

We were also able to introduce a review mechanism. In fact, every deployment goes through two phases:

  • Mandatory Plan: Generates the plan and displays it for review.
  • Manual Apply: Requires explicit human approval.

 

This approach enforces review within a well-established deployment workflow. It does add some latency at deployment time, that’s true — but it also brings a significant and very natural improvement in overall quality.

 
 

Start Small, Measure, Iterate

What really matters to us is reducing the time between an idea and a ready-to-use environment. Here are the implementation times before and after introducing this model:

  • Before: 2–3 weeks for a new environment
  • After: 2–3 hours using the golden paths

 

To achieve this outcome, we continuously learn through regular reviews:

  • Weekly reviews of the most-used modules
  • Quarterly feedback from product teams
  • Adoption metrics for the golden paths

 

Our goal is to optimize the following indicators:

  • Lead time: From commit to production
    • Target: < 30 minutes for a standard Function App
  • Deployment success rate:
    • Target: > 95% success on the first attempt
  • MTTR (Mean Time To Recovery):
    • Target: < 15 minutes for a rollback
  • Share of new services using a golden path:
    • Target: > 80% of new services

 

 

An Architecture That Grows With You

 

This three-layer approach taught us something fundamental: value doesn’t come from technical sophistication, but from clear responsibilities and fast execution.

In practice, this architecture has allowed us to give teams real autonomy without sacrificing governance. At the same time, security standards are applied automatically, eliminating the risk of omissions or non-compliance.

Moreover, deployment cycles for infrastructure and products are now independent, which significantly accelerates delivery. And finally, we benefit from a fast feedback loop that helps the platform evolve based on real-world needs.

 

Lessons Learned

  • Start with workflows, not modules
  • Optimize for the 80% use case
  • Measure adoption, not feature count
  • Document by example—code is the best documentation

 

References