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.
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.

Here is a quick overview of the responsibilities for each tier:
The major advantage of this structure is that it gives teams a readable perimeter:
Here’s an example for a Function App defined by a Product team:
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
}
Each tier maintains its own Terraform state, for example:
terraform-state-org-tiers-{env}terraform-state-enseigne-client-{env}
This separation prevents team conflicts and enables independent lifecycles.
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:
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.
However, there are a few limitations to keep in mind to ensure everything runs smoothly:
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 }}
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 }}
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:
We were also able to introduce a review mechanism. In fact, every deployment goes through two phases:
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.
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:
To achieve this outcome, we continuously learn through regular reviews:
Our goal is to optimize the following indicators:
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.