Terraform is usually used to provision infrastructure such as networks, databases, storage accounts, and cloud services. In this setup, Terraform is being used in a less conventional way: it manages the DevOps configuration that allows the application to build and deploy itself.

The setup-ci-pipeline module lives next to the application code in the same codebase:

src/terraform/setup-ci-pipeline

This is intentional. The repository contains not only the application, but also the Terraform needed to bootstrap its CI identity, GitHub Actions environment, repository variables, Azure permissions, and branch protection rules.

The Bootstrap Problem

Before a CI pipeline can deploy to Azure, it needs an identity. But before that identity exists, the CI pipeline cannot create it for itself.

That is the purpose of this Terraform module.

The module is designed for single-purpose use. It is run locally by a developer using the developer’s own Azure and GitHub identity. That trusted human identity is used once to bootstrap the CI pipeline. After that, GitHub Actions authenticates to Azure independently through OIDC federation.

This separates the initial setup from normal automated deployments.

Managing DevOps Configuration with Terraform

The module treats GitHub configuration as infrastructure. Instead of manually creating environments, variables, service principals, and branch protection rules through the GitHub and Azure portals, those settings are declared in Terraform.

The repository itself is imported and then managed:

data "azuread_client_config" "current" {}

data "azurerm_subscription" "dev" {
  subscription_id = var.subscription
}

locals {
  repository_name = "${var.application_name}-${var.service_name}"
}

import {
  to = github_repository.main
  id = local.repository_name
}

resource "github_repository" "main" {
  name         = local.repository_name
  description  = ""
  homepage_url = ""
  visibility   = "private"
  has_issues   = true
  has_projects = true
  has_wiki     = true
  allow_merge_commit     = true
  allow_rebase_merge     = true
  allow_squash_merge     = false
  allow_auto_merge       = false
  delete_branch_on_merge = true
}

This keeps the repository configuration close to the application code and makes the DevOps setup reproducible.

Creating the GitHub OIDC Identity

The module creates an Azure service principal for GitHub Actions using OIDC federation:

module "github_identity_ci" {
  source  = "Azure-Terraformer/github-credential/azuread"
  version = "1.0.10"
  name                = "app-${var.service_name}-${var.environment_name}"
  github_organization = var.organization
  repository_name     = local.repository_name
  entity_type         = "environment"
  environment_name    = var.environment_name
  owners              = [data.azuread_client_config.current.object_id]
}

This creates a trust relationship between Azure and a specific GitHub repository environment. No client secret is stored in GitHub.

Configuring the GitHub Actions Environment

The module creates the GitHub Actions environment and stores the Azure authentication values as environment variables:

resource "github_repository_environment" "ci" {
  environment         = var.environment_name
  repository          = github_repository.main.name
  prevent_self_review = true
}
resource "github_actions_environment_variable" "subscription_id" {
  repository    = github_repository.main.name
  environment   = github_repository_environment.ci.environment
  variable_name = "ARM_SUBSCRIPTION_ID"
  value         = data.azurerm_subscription.dev.subscription_id
}
resource "github_actions_environment_variable" "tenant_id" {
  repository    = github_repository.main.name
  environment   = github_repository_environment.ci.environment
  variable_name = "ARM_TENANT_ID"
  value         = data.azuread_client_config.current.tenant_id
}
resource "github_actions_environment_variable" "client_id" {
  repository    = github_repository.main.name
  environment   = github_repository_environment.ci.environment
  variable_name = "ARM_CLIENT_ID"
  value         = module.github_identity_ci.service_principal.client_id
}

These are variables, not secrets. The sensitive credential is replaced by short-lived OIDC authentication at runtime.

Assigning Azure Permissions

The CI identity is granted permission to deploy infrastructure and access the shared Key Vault:

resource "azurerm_role_assignment" "ci_subscription_owner" {
  principal_id                     = module.github_identity_ci.service_principal.object_id
  scope                            = data.azurerm_subscription.dev.id
  role_definition_name             = "Owner"
  skip_service_principal_aad_check = true
}

data "azurerm_key_vault" "shared" {
  name                = var.keyvault.name
  resource_group_name = var.keyvault.resource_group
}

resource "azurerm_role_assignment" "ci_keyvault_admin" {
  principal_id                     = module.github_identity_ci.service_principal.object_id
  scope                            = data.azurerm_key_vault.shared.id
  role_definition_name             = "Key Vault Administrator"
  skip_service_principal_aad_check = true
}

The subscription role allows infrastructure deployment. The Key Vault role allows workflows to retrieve build and deployment secrets without embedding them in the repository.

Enforcing Branch Protection

The module also manages repository governance. For the develop branch:

resource "github_branch_protection" "develop" {
  repository_id = github_repository.main.node_id
  pattern       = "develop"
  required_pull_request_reviews {
    required_approving_review_count = 1
    dismiss_stale_reviews           = true
    require_code_owner_reviews      = true
    restrict_dismissals             = true
  }
  require_conversation_resolution = true
  required_linear_history         = false
  enforce_admins                  = false
}

And for the main branch:

resource "github_branch_protection" "main" {
  repository_id = github_repository.main.node_id
  pattern       = "main"
  required_pull_request_reviews {
    required_approving_review_count = 1
    dismiss_stale_reviews           = true
    require_code_owner_reviews      = true
    restrict_dismissals             = true
  }
  require_conversation_resolution = true
  required_linear_history         = false
  enforce_admins                  = false
}

This ensures that repository policy is not configured manually or left to drift over time.

Example Variable Values

A local developer can provide values like this, using generic placeholders for organization, subscription, and shared Key Vault details:

application_name = "example-application"
service_name     = "example-service"
environment_name = "ci"
subscription     = "00000000-0000-0000-0000-000000000000"
organization     = "example-organization"
keyvault = {
  name           = "kv-example-shared"
  resource_group = "rg-example-devops-shared"
}

The repository name is derived from:

{application_name}-{service_name}

For example:

example-application-example-service

Running the Module

The module is run locally during initial setup:

cd src/terraform/setup-ci-pipeline
terraform init
terraform apply

The developer running this command must have enough Azure and GitHub permissions to create the service principal, assign Azure roles, configure the GitHub repository environment, create GitHub Actions variables, and manage branch protection.

Workflow Requirements

After the bootstrap is complete, GitHub Actions workflows can authenticate to Azure by declaring the environment and requesting an OIDC token:

environment: ci
permissions:
  id-token: write

The workflow then uses the variables created by Terraform:

- uses: azure/login@v2
  with:
    client-id: $
    tenant-id: $
    subscription-id: $

No Azure client secret is required.

Conclusion

This use of Terraform is definitely unconventional but very practical. It does not only manage Azure configuration. It also manages the configuration required for the application to build and deploy securely.

Because the module lives beside the application code, the repository becomes self-contained. It includes the application, the infrastructure definition, and the bootstrap configuration for its own CI pipeline.

The module is run once locally by a developer to establish the initial trust relationship. After that, GitHub Actions uses OIDC federation to authenticate to Azure without stored credentials, while branch protection, repository variables, Azure permissions, and CI configuration remain defined as code.

I use modules like this to create more consistency and repeatability when setting up new microservices.