Everything you need to understand β Setup β Infrastructure β CI/CD β Approvals β PR Flow β Security β Azure OIDC β Environments β Backend β Modules β Execution β Best Practices β Governance
This README is designed so even a new DevOps engineer can understand:
π Terraform
π Azure
π GitHub Actions
π OIDC
π Multi-Environment Pipelines
π Production Safety
π Dynamic Approvals
π PR-Only Change Flow
π Self-Hosted Runners
π Secure State Backends
π Environments & Protected Branches
This project showcases enterprise-grade DevOps automation using Terraform + Azure + GitHub Actions with:
- Modular Terraform structure
- Multi-environment CI/CD
- Secure OIDC authentication
- Branch protection (PR mandatory)
- Plan-only on PRs
- Apply with approval on push
- Full manual control through workflow_dispatch
- Self-hosted runners for better performance
- Remote backend with Azure Storage
- Federated credentials (no secrets)
- Environment protections (Prod/UAT/QA/Test)
In short:
β Fully automated Infrastructure-as-Code pipeline
β Zero secrets authentication
β Enterprise safety with approvals & protected branches
β GitOps-style workflow
Developer β PR β Plan β Review β Merge β Push β Apply (Approval) β Azure Infra
This repository follows a clean, scalable, modular approach:
infra/
β
βββ main.tf
βββ provider.tf
βββ variables.tf
βββ terraform.tfvars
βββ outputs.tf
β
βββ modules/
βββ resourceGroup/
βββ networking/
β βββ vnet
β βββ subnet
β βββ nsg
β βββ public-ip
βββ virtual_machine/
βββ database/
βββ loadBalancer/
- Reusable modules
- Clean separation
- Easy to scale
- Easy to maintain
- Supports multi-environment deployment
Terraform state is stored safely in Azure Storage:
- Storage Account
- Container
- State file for each environment:
- dev.tfstate
- qa.tfstate
- test.tfstate
- uat.tfstate
- prod.tfstate
Instead of storing Client Secret in GitHub, you use OpenID Connect:
- More secure
- No secrets leakage
- Azure trusts GitHub identity tokens
- Federated credentials bind repo β branch/environment
OIDC Setup Includes:
- Azure App Registration
- Role assignment
- Federated Credential
- GitHub Actions Login
Every environment has:
- Protected deployment
- Required reviewers
- Optional environment secrets
- Approval gating
Especially prod, where Apply ALWAYS requires approval (on push).
Direct push to main is blocked
Developers MUST create a pull request.
This ensures:
- Code review
- Terraform plan validation
- No accidental deployments
Branch protection settings:
- Require PR
- Require plan job to pass
- Require review
- Block direct pushes
Runs:
- Init
- Fmt
- Validate
- Plan
β No Apply
β No Destroy
Used for:
- Reviewing Terraform changes
- Validating infrastructure impact
This triggers full pipeline:
Init β Fmt β Validate β Plan β Apply
BUT:
- Apply ALWAYS requires approval
- Approval based on environment protection
This ensures:
- Code review before merge
- Change impact known before merge
- Approval before actual infra modification
You can run:
- Init
- Plan
- Apply
- Destroy
With:
- Optional approvals
- Optional stages
Example:
- Only Plan in prod
- Apply with approval in QA
- Destroy in Test with approval
Each environment job supports:
| Stage | Run Flag | Approval Flag |
|---|---|---|
| Init | do_init |
use_environment_init |
| Plan | do_plan |
use_environment_plan |
| Apply | do_apply |
use_environment_apply |
| Destroy | do_destroy |
use_environment_destroy |
This provides COMPLETE CONTROL.
name: prod
on:
push:
branches:
- main
paths:
- 'environments/prod.tfvars'
pull_request:
branches:
- main
paths:
- 'environments/prod.tfvars'
workflow_dispatch:
inputs:
use_environment_init: { type: boolean, default: false }
do_init: { type: boolean, default: false }
use_environment_plan: { type: boolean, default: false }
do_plan: { type: boolean, default: false }
use_environment_apply: { type: boolean, default: false }
do_apply: { type: boolean, default: false }
use_environment_destroy: { type: boolean, default: false }
do_destroy: { type: boolean, default: false }
permissions:
contents: read
id-token: write
concurrency:
group: prod-tf
cancel-in-progress: false
jobs:
call:
uses: ./.github/workflows/terraform-multi.yml
with:
environment: prod
tfvars_file: environments/prod.tfvars
rgname: ritkargv
saname: ritkasav
scname: ritkascv
key: prod.tfstate
runInit: ${{ github.event_name != 'pull_request' && (github.event_name == 'push' || inputs.do_init == true) }}
runPlan: ${{ github.event_name == 'pull_request' || github.event_name == 'push' || inputs.do_plan == true }}
runApply: ${{ github.event_name == 'push' || inputs.do_apply == true }}
runDestroy: ${{ github.event_name == 'workflow_dispatch' && inputs.do_destroy == true }}
useEnvironmentInit: ${{ github.event_name == 'workflow_dispatch' && inputs.use_environment_init == true }}
useEnvironmentPlan: ${{ github.event_name == 'workflow_dispatch' && inputs.use_environment_plan == true }}
useEnvironmentApply: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.use_environment_apply == true) }}
useEnvironmentDestroy: ${{ github.event_name == 'workflow_dispatch' && inputs.use_environment_destroy == true }}
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}| Event | Init | Plan | Apply | Destroy |
|---|---|---|---|---|
| PR | Yes | Yes | β No | β No |
| Push | Yes | Yes | β Yes (Approval Required) | β No |
| workflow_dispatch | Optional | Optional | Optional | Optional |
Recommended reviewers:
- DevOps Team
- Architects
- Leads
Prod should require:
β Apply approval
β Destroy approval
- Enable soft delete on storage account
- Enable versioning
- Enable blob immutability (optional)
- Use separate container per environment
Advantages:
- Faster plans
- Private network access
- Install Terraform/Azure CLI versions you want
- No GitHub shared runner rate limits
Plan runs β Review β Merge
Push triggers β Full pipeline runs β Apply pauses
Infrastructure updates safely
Safe, manual, gated
Check:
- Environment approval
- Reviewer permissions
- Branch protection
- OIDC binding
Check:
- Federated credential
- Correct branch/environment binding
- OIDC login
Because destroy only runs in workflow_dispatch.
This README teaches:
- Terraform basics
- Modular structure
- Azure backend
- OIDC authentication
- GitHub environment approvals
- Branch protection
- PR flow
- Push behavior
- workflow_dispatch controls
- State handling
- Runner setup
- End-to-end execution
βΆ Β OVERVIEW
A production-grade Infrastructure as Code (IaC) architecture using Terraform + Azure, fully automated using GitHub Actions, supporting multi-environment deployment pipelines:
Dev β QA β Test β UAT β Prod
This version includes major enhancements:
- π₯ Dynamic approval toggles (Init/Plan/Apply/Destroy)
- π Branch protection & PR-only workflow
- π PR Plan-only β Merge β Push Apply-with-Approval
- π¦ Multi-environment backend with tfvars
- π Self-hosted runners + OIDC secure Azure login
- π§ Full control via workflow_dispatch toggles
Developer Commit / PR
β
βΌ
Pull Request (Plan Only)
β
βΌ
Approval + Merge
β
βΌ
Push to Main β Full Pipeline
β
βΌ
Init β Fmt β Validate β Plan
β
βΌ
Apply (Approval Required via Environment Protection)
β
βΌ
Azure Infra Updated
- Fully modular design
- Reusable modules for RG, VNet, NSG, VM, LB, SQL
for_eachbased scalable resource creation- Remote backend using Azure Storage
- Clean variable separation using
variables.tfand environment-based.tfvars - CI/CD optimized folder structure
infra/
βββ main.tf
βββ provider.tf
βββ variables.tf
βββ terraform.tfvars
βββ outputs.tf
βββ modules/
βββ resourceGroup/
βββ networking/
βββ virtual_machine/
βββ database/
βββ loadBalancer/
We now have 3 modes of execution:
| Mode | Trigger | Behavior |
|---|---|---|
| PR | pull_request | π Plan Only (No apply) |
| Push (main) | merge to main | π Full pipeline until Apply β Apply requires Approval |
| workflow_dispatch | manual | π§ Run any stage (Init/Plan/Apply/Destroy) with/without approval |
To ensure production safety:
Developers must:
- Create a PR
- PR runs Plan-only workflow
- Reviewers approve
- Merge allowed
- Push workflow runs with controlled Apply
- Require PR review
- Require status checks β βplanβ job must pass
- Restrict direct pushes
- Optional: Require signed commits
This creates a secure GitOps-style deployment.
- Runs: Init β Validate β Plan
- No Apply
- No Destroy
- Fast feedback for reviewers
- Runs: Init β Validate β Plan β Apply
- BUT Apply pauses waiting for GitHub Environment Approval
Using:
do_init
do_plan
do_apply
do_destroy
use_environment_init
use_environment_plan
use_environment_apply
use_environment_destroy
You control:
- What stages run
- Which stages need approval
- Destroy allowed only via dispatch
name: prod
on:
push:
branches:
- main
paths:
- 'environments/prod.tfvars'
pull_request:
branches:
- main
paths:
- 'environments/prod.tfvars'
workflow_dispatch:
inputs:
use_environment_init: { type: boolean, default: false }
do_init: { type: boolean, default: false }
use_environment_plan: { type: boolean, default: false }
do_plan: { type: boolean, default: false }
use_environment_apply: { type: boolean, default: false }
do_apply: { type: boolean, default: false }
use_environment_destroy: { type: boolean, default: false }
do_destroy: { type: boolean, default: false }
permissions:
contents: read
id-token: write
concurrency:
group: prod-tf
cancel-in-progress: false
jobs:
call:
uses: ./.github/workflows/terraform-multi.yml
with:
environment: prod
tfvars_file: environments/prod.tfvars
rgname: ritkargv
saname: ritkasav
scname: ritkascv
key: prod.tfstate
runInit: ${{ github.event_name != 'pull_request' && (github.event_name == 'push' || inputs.do_init == true) }}
runPlan: ${{ github.event_name == 'pull_request' || github.event_name == 'push' || inputs.do_plan == true }}
runApply: ${{ github.event_name == 'push' || inputs.do_apply == true }}
runDestroy: ${{ github.event_name == 'workflow_dispatch' && inputs.do_destroy == true }}
useEnvironmentInit: ${{ github.event_name == 'workflow_dispatch' && inputs.use_environment_init == true }}
useEnvironmentPlan: ${{ github.event_name == 'workflow_dispatch' && inputs.use_environment_plan == true }}
useEnvironmentApply: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.use_environment_apply == true) }}
useEnvironmentDestroy: ${{ github.event_name == 'workflow_dispatch' && inputs.use_environment_destroy == true }}
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} ββββββββββββββββ
β Pull Request β
ββββββββ¬ββββββββ
βΌ
Init β Validate β Plan
β
(No Apply)
βΌ
Reviewer Approves
βΌ
Merge to Main
βΌ
βββββββββββββ
β Push CI β
βββββββ¬ββββββ
βΌ
Init β Validate β Plan β Apply (Approval Required)
- Passwordless authentication
- No secrets stored
- Secure identity federation
- Environment-based protection
βΆ Β Good To Know Steps
This section explains how to securely connect GitHub Actions β Azure Portal using OpenID Connect (OIDC) and Federated Credentials.
- Go to Azure Portal β Azure Active Directory β App registrations
- Click New registration
- Fill details:
- Name:
github-oidc-terraform-app - Supported account type: Accounts in this organizational directory only
- Redirect URI: Leave blank
- Name:
- Click Register
- Copy the Application (client) ID and Directory (tenant) ID
- Go to your Azure Subscription β Access Control (IAM) β Add role assignment
- Choose a role (e.g.,
Contributor) - Select Members β Assign access to User, Group, or Service Principal
- Find and select your App Registration
- Click Review + Assign
- Open your App Registration β Certificates & Secrets β Federated Credentials
- Click Add Credential
- Fill in details:
| Field | Description |
|---|---|
| Federated credential scenario | GitHub Actions deploying Azure resources |
| Organization | Your GitHub Organization name |
| Repository | Your repository name |
| Entity Type | Choose Environment or Branch |
| Environment/Branch Name | Example: prod or main |
| Name | Example: prod-deploy-oidc |
- Click Add
Note: If user doesnβt see that option, they can manually choose βOtherβ and fill the repo/org details
When creating Federated Credentials in your Azure App Registration, Azure needs to know βfrom where GitHub will send identity tokensβ.
Thatβs where you must choose either a Branch or an Environment, depending on how your pipeline is triggered.
β Use this when your workflows run automatically on every code push or PR.
Example Use Case:
- You want Terraform to plan/deploy automatically every time someone pushes to
main,dev, orfeature/*branch. - No manual approval is needed β pipeline runs instantly.
Azure Setup:
- In Federated Credential setup:
- Choose Entity Type:
Branch - Enter Branch name:
mainordev
- Choose Entity Type:
- Azure will trust GitHub tokens coming only from that branch.
GitHub Example:
on:
push:
branches:
- main
- devπ§ So here, as soon as you push β OIDC auth + Terraform runs automatically.
β Use this when you need manual approvals before applying or destroying infrastructure.
Example Use Case:
- You have environments like
dev,qa,prod. - You want
terraform planto run automatically, butterraform applyshould wait for approval.
Azure Setup:
- In Federated Credential setup:
- Choose Entity Type:
Environment - Enter Environment name:
prodorqa
- Choose Entity Type:
- Azure will now only trust GitHub tokens when the job is tied to that environment.
GitHub Example:
jobs:
apply:
environment:
name: prod
runs-on: ubuntu-latestπ§ Here, when the job reaches environment: prod,
GitHub sends an approval request to reviewers β once approved β OIDC token is validated β job executes.
| Scenario | Entity Type | When to Use |
|---|---|---|
| Continuous Integration (auto deploy on push) | Branch | Dev/Test pipelines that run frequently |
| Controlled Deployment (manual approval needed) | Environment | QA/Prod pipelines that need approval |
π¬ Rule of Thumb:
- Use Branch for speed & automation.
- Use Environment for safety & compliance.
Example GitHub Action step:
- name: Azure Login via OIDC
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}β Once this step succeeds, your workflow is authenticated to Azure via OIDC.
- Push code or trigger the workflow.
- GitHub sends an OIDC token to Azure.
- Azure validates it using the Federated Credential.
- If valid β authentication succeeds β Terraform runs securely.
Typical GitHub Actions Workflow:
jobs:
terraform-apply:
environment:
name: prod
url: https://portal.azure.com
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Azure
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Terraform Init & Plan
run: |
terraform init
terraform plan
- name: Terraform Apply (Manual Approval Required)
run: terraform apply -auto-approveπ§ The environment block enforces manual approval before apply or destroy executes.
β
Secure GitHub-to-Azure connection using OIDC (no passwords).
β
Enforced manual approval with environments.
β
Centralized secret management via GitHub Actions.
β
Fully automated Terraform deployment workflow.
We use GitHub Environments to enforce manual approvals for critical stages like
applyanddestroy.
When a workflow job references an environment, GitHub automatically pauses the job and sends an approval request to the configured reviewers.
The job resumes only after one or more reviewers approve the request.
- Go to your repository β Settings β Collaborators & Teams.
- Add the users or teams who will act as approvers for environment deployments.
- (Recommended) Create a GitHub Team (e.g.,
infra-approvers) to manage permissions easily.
- Go to Settings β Environments β New Environment.
- Create a separate environment for each stage:
dev,qa,test,uat,prod, etc. - Each environment should represent a logical stage in your deployment pipeline.
- Click on each environment name β set Protection Rules.
- Under Required reviewers, select the collaborators or teams added earlier.
- Optionally, configure:
- Wait timer (delay before auto-deployment),
- Deployment branch policies, and
- Minimum number of required reviewers.
- Under each environment, go to Secrets β Add Secret.
- Store sensitive data (e.g., credentials, API keys) specific to that environment.
- These secrets are only accessible by jobs that use this environment.
In your GitHub Actions workflow (e.g., terraform-multi.yaml), define the environment key in jobs that require approval.
This project uses GitHub Secrets to store sensitive credentials required for authentication and deployment via Terraform.
Secrets are encrypted and securely managed by GitHub. They can be defined either:
- At the Repository level (accessible by all workflows)
- Or at the Environment level (restricted to specific stages like
dev,qa,prod)
The following secrets are mandatory for Azure-based Terraform authentication (via OIDC):
| Secret Name | Description |
|---|---|
AZURE_CLIENT_ID |
The Azure AD App (Service Principal) client ID |
AZURE_TENANT_ID |
The Azure Active Directory tenant ID |
AZURE_SUBSCRIPTION_ID |
The Azure subscription ID used for deployment |
- Go to your GitHub repository.
- Click on Settings β Secrets and variables β Actions.
- Under the Repository secrets section, click on New repository secret.
- Add each of the following secrets one by one:
- Name:
AZURE_CLIENT_IDβ Value: Your Azure Appβs Client ID - Name:
AZURE_TENANT_IDβ Value: Your Azure Tenant ID - Name:
AZURE_SUBSCRIPTION_IDβ Value: Your Azure Subscription ID
- Name:
- Click Add secret after each entry.
Once saved, the secrets appear under the repository secrets list β
youβll see small lock icons π indicating theyβre encrypted and secure.
If you use GitHub Environments (e.g., dev, qa, prod), you can add environment-specific secrets too:
- Go to Settings β Environments β [Select environment] β Manage environment secrets.
- Add secrets specific to that environment (for example, separate Azure accounts per stage).
In your workflow YAML (e.g., terraform-multi.yaml), you reference secrets like this:
- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}To ensure your CI/CD pipeline and infrastructure remain secure and compliant, always follow these recommended practices:
- Prefer environment-level secrets instead of global repository secrets.
- This ensures tighter access control β for example:
devβ test credentialsqaβ staging credentialsprodβ real production credentials
- Environment secrets can only be accessed by jobs running in that environment.
- Periodically regenerate Azure credentials (App registrations, service principals).
- Update them immediately in your GitHub secrets.
- This minimizes the risk of leaked or stale credentials being reused.
- Avoid using
echo,print, orterraform outputcommands that might reveal secrets. - GitHub automatically masks secrets in logs, but avoid printing any variable containing them.
- Example of what not to do:
- run: echo "Client ID: ${{ secrets.AZURE_CLIENT_ID }}" # β Unsafe
Keeping your secrets secure also means controlling who can manage them. Follow these steps:
-
β Allow only trusted collaborators or admins to edit secrets.
This limits potential security risks from unauthorized changes. -
βοΈ Navigate to Repository β Settings β Manage Access
Here you can view and modify collaborator permissions. -
π Review access regularly β remove inactive users or anyone who no longer needs secret management privileges.
-
π Keep a minimal privilege policy β βleast privilege principleβ always applies.
Before you deploy to production, make sure all configurations and secrets are valid:
-
π§ͺ Test your workflows in a non-production environment first (like
devorqa).
This prevents accidental deployments or resource destruction in live systems. -
π Run
terraform planbeforeterraform apply.
This checks authentication, access roles, and infrastructure changes without making modifications. -
π΅οΈββοΈ Validate all Azure credentials (Client ID, Tenant ID, Subscription ID)
to ensure they match the correct environment setup.
.gitignoreexcludes*.tfstate,terraform.tfvars, and.terraform/- Secrets never committed to code
- Each environment isolated with separate state files
Ritesh Sharma
πΌ DevOps Engineer | Azure | Terraform | CI/CD | Docker | Kubernetes
This project is licensed under the MIT License.
You are free to use and modify for educational and personal purposes.
π§© βCode privately. Deploy publicly. Automate everything.β