A hands-on Terraform learning repository created to understand how Terraform, Azure, and GitHub Actions can be combined to implement a basic CI/CD workflow with DevSecOps practices.
The infrastructure in this repository is intentionally simple. It contains a single Azure Resource Group child module so that the primary focus remains on understanding Terraform workflow automation, GitHub Actions jobs, security scanning, environments, approvals, and deployment flow.
The GitHub Actions workflow has been enhanced to make the Terraform CI/CD process more controlled, observable, and closer to a real-world workflow.
The workflow now runs:
terraform plan -detailed-exitcode -out=tfplanThe exit code is interpreted as follows:
| Exit Code | Meaning | Workflow Result |
|---|---|---|
0 |
Plan succeeded and no infrastructure changes are required | Successful plan, no Apply |
2 |
Plan succeeded and infrastructure changes are detected | Successful plan, Apply can proceed |
| Other | Terraform plan failed | Plan fails and Apply does not run |
The Plan job exposes two outputs:
changes
status
These outputs are consumed by the Apply job to determine whether deployment should take place.
When Terraform detects changes, the generated plan file:
tfplan
is uploaded as a GitHub Actions artifact named:
terraform-plan
The workflow therefore follows:
Terraform Plan
│
▼
tfplan
│
▼
Upload Artifact
│
▼
Apply Job
│
▼
Download Artifact
│
▼
terraform apply tfplan
The Apply job now runs only when all of the following conditions are satisfied:
main branch
AND
plan status = success
AND
plan detected changes
The condition is:
if: github.ref == 'refs/heads/main' &&
needs.plan.outputs.changes == 'true' &&
needs.plan.outputs.status == 'success'Therefore:
No changes
│
▼
No Apply
while:
Changes detected
│
▼
Successful Plan
│
▼
Apply
The Security Scan job now creates a TFLint report:
tflint-report.txt
and uploads it as:
scan-reports
with a retention period of 5 days.
The upload step uses:
if: always()so the report can still be uploaded when an earlier scanning step fails.
The scan stage now provides:
Security Scan
│
├── Gitleaks
├── TruffleHog
└── TFLint
│
▼
tflint-report.txt
│
▼
scan-reports
TruffleHog now runs with:
--results=verified,unknown --json
This produces machine-readable JSON output, making the scan results easier to preserve, process, or integrate into future reporting and security workflows.
The Apply stage downloads the terraform-plan artifact and executes:
terraform apply -auto-approve tfplanThis is an important improvement over running:
terraform apply -auto-approvebecause the Apply stage now uses the plan generated during the Plan stage rather than creating a new plan during deployment.
The latest workflow can be summarized as:
Git Push
│
▼
Security Scan
│
├── Gitleaks
├── TruffleHog
└── TFLint
│
▼
Scan Reports
│
▼
Terraform Plan
│
├── fmt
├── init
├── validate
└── plan -detailed-exitcode
│
┌──────┴──────┐
│ │
No Changes Changes
│ │
▼ ▼
No Apply Upload tfplan
│
▼
Apply Job
│
▼
Production Environment
│
▼
Reviewer Approval
│
▼
Download tfplan
│
▼
terraform apply tfplan
This update introduces an important CI/CD concept: the deployment stage consumes the artifact produced by the planning stage, while the workflow avoids deployment when Terraform reports that no infrastructure changes are required.
This repository is designed to provide hands-on practice with:
- Terraform root and child modules
- Terraform formatting and validation
- Terraform initialization, planning, and applying
- GitHub Actions workflows
- Workflow triggers and path filters
- Job dependencies using
needs - Conditional job execution using
if - Terraform security and code-quality scanning
- Gitleaks and TruffleHog
- TFLint
- Azure authentication using GitHub OIDC
- GitHub Environments
- Development and Production environment separation
- Production deployment approval
- GitHub branch protection
- CI/CD concepts for Infrastructure as Code
workflow-basic-1/
│
├── .github/
│ └── workflows/
│ └── terraform.yaml
│
├── environment/
│ └── dev/
│ ├── main.tf
│ ├── provider.tf
│ ├── terraform.tfvars
│ └── variables.tf
│
├── modules/
│ └── azurerm\_resource\_group/
│ ├── main.tf
│ └── variable.tf
│
├── .gitignore
└── README.md
The repository follows a simple root module → child module structure.
environment/dev
│
│ module call
▼
modules/azurerm\_resource\_group
│
▼
Azure Resource Group
The Terraform root configuration is located at:
environment/dev
It contains:
main.tfprovider.tfterraform.tfvarsvariables.tf
This directory is used as the Terraform working directory by GitHub Actions.
The repository contains one child module:
modules/azurerm\_resource\_group
The module contains:
main.tf
variable.tf
Its purpose is to demonstrate how a root module can consume a reusable Terraform child module to create an Azure Resource Group.
The module is intentionally simple because this repository focuses primarily on learning the CI/CD workflow rather than building a large infrastructure platform.
The GitHub Actions workflow is located at:
.github/workflows/terraform.yaml
The workflow is triggered by pushes, while README-only changes are ignored:
on:
push:
paths-ignore:
- '**/README.md'This means changes to documentation files matching **/README.md do not trigger the Terraform workflow.
This is useful because a documentation-only change does not require Terraform security scanning, planning, or deployment.
The workflow uses the following permissions:
permissions:
id-token: write
contents: readAllows the workflow to check out and read repository contents.
Allows GitHub Actions to request an OIDC token.
The OIDC token is then used by Azure authentication so that the workflow can authenticate to Azure without storing a long-lived Azure client secret.
The first job is the Security Scan job.
It performs:
Security Scan
│
├── Gitleaks
│
├── TruffleHog
│
└── TFLint
The purpose is to identify potential security and Terraform configuration issues before infrastructure changes proceed to the planning stage.
Gitleaks is used to scan the repository for accidentally committed secrets and sensitive information.
Examples include:
- API keys
- passwords
- tokens
- private keys
- cloud credentials
TruffleHog is another secret-scanning tool used to search the repository for potentially exposed credentials.
The workflow uses:
--results=verified,unknown
The configuration requests verified and unknown findings.
TFLint is used to lint Terraform configuration and identify Terraform-specific issues and best-practice violations.
The workflow:
1. Installs TFLint 2. Displays the installed version 3. Initializes TFLint 4. Runs TFLint
Example:
- name: Setup TFLint
uses: terraform-linters/setup-tflint\@v6
- name: Init TFLint
run: tflint --init
- name: Run TFLint
run: tflint -f compactThe Terraform Plan job depends on the Security Scan job:
needs: scanThe dependency creates this relationship:
Security Scan
│
│ success
▼
Terraform Plan
If the scan job fails, the plan job does not proceed.
The Terraform Plan job runs from:
environment/dev
The job performs:
Terraform fmt
│
▼
Terraform init
│
▼
Terraform validate
│
▼
Terraform plan
Checks whether the Terraform configuration is correctly formatted:
terraform fmt -checkInitializes the Terraform working directory and downloads the required provider/module dependencies:
terraform initValidates the Terraform configuration:
terraform validateCreates an execution plan showing what Terraform intends to change:
terraform planThis workflow uses two GitHub Environments:
development
production
They represent two different stages of the deployment lifecycle.
Terraform Plan
│
▼
development
│
▼
production
│
▼
Manual Approval
│
▼
Terraform Apply
The Plan job uses:
environment:
name: developmentThe Development environment does not have a required reviewer.
Its purpose is to represent the non-production stage of the workflow.
The Terraform Plan is associated with this environment.
The Apply job uses:
environment:
name: productionThe Production environment has a required reviewer configured in GitHub.
When the workflow reaches the Production environment, GitHub pauses the deployment until an authorized reviewer approves it.
The flow becomes:
Terraform Apply Job
│
▼
Production Environment
│
▼
Reviewer Approval
│
┌────┴────┐
│ │
APPROVE REJECT
│ │
▼ ▼
Apply Stop
This demonstrates how GitHub Environments can be used to introduce a manual approval gate before production deployment.
The Apply job is responsible for deploying the Terraform configuration.
It is configured with:
if: github.ref == 'refs/heads/main'and:
needs: planTherefore, the intended dependency is:
Plan
│
│ success
▼
Apply
│
▼
Production Environment
│
│ approval
▼
Terraform Apply
The Apply job uses:
terraform init
terraform apply -auto-approveThe Terraform working directory remains:
environment/dev
The complete workflow can be visualized as:
Git Push
│
▼
README-only change?
/ \\
YES NO
│ │
▼ ▼
Ignore Security Scan
│
┌───────────┼───────────┐
│ │ │
▼ ▼ ▼
Gitleaks TruffleHog TFLint
│ │ │
└───────────┼───────────┘
│
SUCCESS
│
▼
Terraform Plan
│
┌────────┼────────┐
│ │ │
▼ ▼ ▼
fmt init validate
│
▼
plan
│
▼
Development
Environment
│
▼
Plan Success
│
▼
Apply Job
│
▼
Production
Environment
│
▼
Manual Approval
│
┌─────┴─────┐
│ │
APPROVE REJECT
│ │
▼ ▼
Terraform Deployment
Apply stops
The main branch is protected.
Branch protection is an important part of the CI/CD design because it prevents changes from being merged into main without satisfying the configured repository rules.
The intended development model is:
Feature Branch
│
▼
Pull Request
│
▼
Required Checks
│
▼
Code Review
│
▼
Merge to main
The repository therefore demonstrates the principle of using GitHub repository controls together with CI checks to protect the main infrastructure branch.
The workflow authenticates to Azure using GitHub Actions OIDC.
Azure login is performed using:
- name: Azure login
uses: azure/login\@v3
with:
client-id: ${{ secrets.AZURE\_CLIENT\_ID }}
tenant-id: ${{ secrets.AZURE\_TENANT\_ID }}
subscription-id: ${{ secrets.AZURE\_SUBSCRIPTION\_ID }}The workflow therefore needs:
AZURE\_CLIENT\_ID
AZURE\_TENANT\_ID
AZURE\_SUBSCRIPTION\_ID
configured as GitHub secrets.
The important security advantage is that the workflow does not need to store a long-lived Azure client secret.
The authentication model is:
GitHub Actions
│
│ OIDC token
▼
Microsoft Entra ID
│
│ Federated Identity
▼
Azure Service Principal / App Registration
│
▼
Azure Subscription
The workflow demonstrates GitHub Actions job dependencies using needs.
The primary dependency is:
plan:
needs: scanand:
apply:
needs: planThis creates:
scan
│
▼
plan
│
▼
apply
This is an important GitHub Actions concept because jobs normally run independently unless a dependency is explicitly defined.
The Apply job contains:
if: github.ref == 'refs/heads/main'This demonstrates conditional execution of a GitHub Actions job.
The condition evaluates the Git reference and allows the Apply job to run only when the workflow is executing against main.
The workflow installs Terraform using:
- uses: hashicorp/setup-terraform\@v4
with:
terraform\_version: "1.14.6"Pinning the Terraform version helps keep the workflow consistent and predictable across GitHub-hosted runners.
- Root modules
- Child modules
- Variables
- Provider configuration
- Terraform state
terraform fmtterraform initterraform validateterraform planterraform apply
- Workflow triggers
- Path filters
- Jobs
- Steps
- Job dependencies
needs- Conditional execution with
if - Working directories
- GitHub secrets
- OIDC authentication
- GitHub Environments
- Gitleaks
- TruffleHog
- TFLint
- Automated validation
- Branch protection
- Production approval gates
- Azure Resource Group
- Microsoft Entra ID / App Registration
- Federated Identity Credentials
- OIDC authentication
- Azure subscription authentication
The repository is intentionally small so that concepts can be introduced progressively.
A typical learning progression is:
Terraform Basics
│
▼
Terraform Modules
│
▼
Git & GitHub
│
▼
GitHub Actions
│
▼
Terraform CI
│
▼
Security Scanning
│
▼
Terraform Plan
│
▼
GitHub Environments
│
▼
Production Approval
│
▼
Terraform Apply
The objective is to understand not only what each tool does, but also why it is placed at a particular stage of the workflow.
This repository can be progressively extended as additional Terraform and DevOps concepts are learned.
Possible future improvements include:
- Checkov
- Infracost
- Terraform test
- Remote Terraform state using Azure Storage
- State locking and state management
- Multiple reusable Terraform modules
- Dev / Test / UAT / Production environments
- Environment promotion
- Reusable GitHub Actions workflows
- Composite actions
- Matrix-based Terraform workflows
- Deployment protection rules
- Advanced manual approval strategies
- Cost estimation
- Additional policy-as-code checks
- More advanced DevSecOps controls
- Terraform drift detection
- Automated infrastructure testing
- Notifications and deployment reporting
This repository is part of a hands-on learning journey focused on:
Terraform • Azure • GitHub Actions • CI/CD • DevSecOps • Infrastructure as Code
The infrastructure is intentionally simple. The goal is to progressively build understanding by adding one concept at a time rather than starting with a complex enterprise implementation.
> Learn the infrastructure. Understand the workflow. Automate the deployment.
This repository is intended primarily as a learning and practice project.
Sanjeev Kumar Singh
Terraform • Azure • GitHub Actions • DevSecOps