Using Terraform to Bootstrap a GitHub CI Process
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.