CI/CD for infrastructure lets non-software companies define servers, networks, and cloud resources as version-controlled code files, submit changes through GitHub pull requests for peer review, and apply them through automated pipelines — replacing the manual click-through-a-portal process that leaves no audit trail, no undo capability, and no documentation of what changed or why. Organizations using automated Terraform workflows deploy infrastructure changes significantly faster and recover from mistakes in minutes instead of hours.
Your IT person makes a firewall change by remoting into the server, clicking through a GUI, and hoping they remember which rule they modified two weeks from now when something breaks. They update a DNS record by logging into the registrar’s dashboard and typing values by hand. They provision a new cloud resource by clicking through the Azure portal, selecting options from dropdowns, and never documenting what they chose or why.
CI/CD for infrastructure applies continuous integration and continuous deployment practices to infrastructure changes. Instead of manually configuring servers and cloud resources, you define infrastructure as code in version-controlled configuration files, submit changes through pull requests for peer review, and let automated pipelines validate and apply those changes. Every infrastructure modification is documented, reviewed, tested, and repeatable.
This is not how most small businesses in Volusia County manage their infrastructure. Someone logs into a server or a cloud portal, makes a change, and moves on. No version control. No peer review. No audit trail. No ability to undo the change if it breaks something. The infrastructure configuration exists only in the current state of the server and the memory of whoever made the last change.
CI/CD fixes all of this. And despite what the acronym might suggest, you don’t need to be a software company to use it. You need a GitHub account, some Terraform configuration files, and about a day of setup time.
Why Infrastructure CI/CD Matters for Non-Software Companies
Let me tell you what happens without CI/CD. A VPN stopped working after an IT contractor made “a small firewall change.” Nobody knew exactly what the contractor changed. The contractor didn’t remember. There was no documentation, no change log, no before-and-after comparison. Four hours were spent reverse-engineering the firewall configuration to figure out which rule was wrong. Four hours of downtime, four hours of billable consulting time, all because a change was made without a review process.
With infrastructure CI/CD, that scenario is impossible. The contractor would have submitted a pull request with the proposed firewall rule change. The change would be visible as a diff — here’s what the firewall looks like now, here’s what it’ll look like after the change. Someone reviews it. The pipeline validates the syntax. When approved, the pipeline applies the change. If something breaks, you revert the pull request and the pipeline restores the previous configuration. The entire history is preserved in Git.
Organizations using automated Terraform workflows deploy infrastructure changes significantly faster than those relying on manual processes, and more importantly, they recover from mistakes in minutes instead of hours. For a fifteen-person business that depends on its cloud infrastructure being available, the difference between minutes and hours of recovery time is the difference between a minor hiccup and a full day of lost productivity.
The other benefit is accountability. When every infrastructure change goes through a pull request, you have a permanent record of who changed what, when, and why. Three months from now, when someone asks “why was this security group rule added?” the answer is in the pull request, including the discussion about whether it was a good idea.
What You’re Actually Building
Continuous Integration (CI) means that every proposed change is automatically validated before it’s applied. When you submit a Terraform change through a pull request, the CI pipeline runs terraform validate to check syntax, runs terraform plan to show what will change, and optionally runs security scanning tools to check for misconfigurations.
Continuous Deployment (CD) means that approved changes are automatically applied to your infrastructure. When a pull request is reviewed, approved, and merged, the CD pipeline runs terraform apply to make the changes real. No one logs into a server. No one clicks through a portal. The pipeline does the work.
GitHub Actions is the platform that runs your CI/CD pipelines. It’s built into GitHub, which means there’s no separate CI/CD server to set up or maintain.
Terraform is the tool that defines your infrastructure as code. Instead of clicking through the Azure portal to create a virtual machine, you write a configuration file that describes the VM you want. Terraform reads that file and creates the VM.
Prerequisites
A GitHub account and repository. Free tier works fine. Create a private repository for your infrastructure code.
Terraform installed locally. Download from terraform.io — it’s a single binary. Add it to your PATH.
An Azure subscription (or AWS, or Google Cloud — the pattern works with all three). This guide uses Azure.
An Azure service principal for GitHub Actions to authenticate with. We’ll create this with least-privilege permissions — the service principal can only manage the resources in a specific resource group, not your entire subscription.
Step 1: Set Up Your Repository Structure
Create a repository with this structure:
infrastructure/
├── .github/
│ └── workflows/
│ ├── terraform-plan.yml # Runs on pull requests
│ └── terraform-apply.yml # Runs on merge to main
├── environments/
│ ├── production/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── terraform.tfvars
│ └── staging/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── terraform.tfvars
├── modules/
│ ├── networking/
│ │ ├── main.tf
│ │ └── variables.tf
│ └── compute/
│ ├── main.tf
│ └── variables.tf
├── .gitignore
└── README.md
Your .gitignore should exclude Terraform state files and credentials:
# .gitignore
*.tfstate
*.tfstate.backup
.terraform/
*.tfvars.secret
crash.log
Step 2: Create the Azure Service Principal
# Log in to Azure CLI
az login
# Create a service principal scoped to a resource group
az ad sp create-for-rbac \
--name "github-actions-terraform" \
--role "Contributor" \
--scopes "/subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/rg-infrastructure" \
--json-auth
The –scopes flag is critical. It limits the service principal to a single resource group. If the credential is compromised, the attacker can only affect resources in that resource group — not your entire Azure subscription. Never create a service principal with subscription-level Contributor access for CI/CD.
Store the output as GitHub repository secrets. In your repository, go to Settings > Secrets and variables > Actions and create:
- AZURE_CLIENT_ID
- AZURE_CLIENT_SECRET
- AZURE_SUBSCRIPTION_ID
- AZURE_TENANT_ID
Step 3: Write Your Terraform Configuration
Here’s a practical example — a Terraform configuration that manages a small business Azure environment with a virtual network, network security group, and a web server VM: We cover this in more detail in The ‘Good Enough’ Cloud Setup for Businesses Under 20 Employees.
# environments/production/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.85"
}
}
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "stterraformstate001"
container_name = "tfstate"
key = "production.terraform.tfstate"
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "main" {
name = var.resource_group_name
location = var.location
tags = {
environment = "production"
managed_by = "terraform"
team = "infrastructure"
}
}
resource "azurerm_virtual_network" "main" {
name = "vnet-production-001"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
}
resource "azurerm_subnet" "web" {
name = "snet-web"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.1.0/24"]
}
resource "azurerm_network_security_group" "web" {
name = "nsg-web-production"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "AllowHTTPS"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowSSHFromOffice"
priority = 200
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.office_ip_range
destination_address_prefix = "*"
}
security_rule {
name = "DenyAllInbound"
priority = 4096
direction = "Inbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
resource "azurerm_subnet_network_security_group_association" "web" {
subnet_id = azurerm_subnet.web.id
network_security_group_id = azurerm_network_security_group.web.id
}
# environments/production/variables.tf
variable "resource_group_name" {
description = "Name of the resource group"
type = string
default = "rg-production-001"
}
variable "location" {
description = "Azure region for resources"
type = string
default = "eastus"
}
variable "office_ip_range" {
description = "Office IP range for SSH access"
type = string
}
Notice the backend block in the Terraform configuration. This stores your Terraform state file in an Azure Storage Account instead of locally. Remote state with locking prevents concurrent modifications and gives your pipeline a consistent view of what infrastructure exists.
Step 4: Build the CI Pipeline (Plan on Pull Request)
# .github/workflows/terraform-plan.yml
name: Terraform Plan
on:
pull_request:
branches: [main]
paths:
- "environments/**"
- "modules/**"
permissions:
contents: read
pull-requests: write
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
TF_VERSION: "1.7.0"
jobs:
plan-production:
name: Plan Production Changes
runs-on: ubuntu-latest
defaults:
run:
working-directory: environments/production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Format Check
id: fmt
run: terraform fmt -check -recursive
continue-on-error: true
- name: Terraform Init
id: init
run: terraform init
- name: Terraform Validate
id: validate
run: terraform validate -no-color
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
continue-on-error: true
- name: Comment Plan on PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const output = `### Terraform Plan Results
**Format Check:** \`${{ steps.fmt.outcome }}\`
**Init:** \`${{ steps.init.outcome }}\`
**Validate:** \`${{ steps.validate.outcome }}\`
**Plan:** \`${{ steps.plan.outcome }}\`
<details>
<summary>Plan Output (click to expand)</summary>
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\`
</details>
*Pushed by: @${{ github.actor }}*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
- name: Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
The terraform plan step generates a plan showing exactly what Terraform will create, modify, or destroy. The Comment Plan on PR step posts this plan as a comment on the pull request. This means the reviewer can see exactly what will happen to production infrastructure without running any commands themselves. For related strategies, check out Building a Zero-Touch Deployment Pipeline for Windows Workstations.
Step 5: Build the CD Pipeline (Apply on Merge)
# .github/workflows/terraform-apply.yml
name: Terraform Apply
on:
push:
branches: [main]
paths:
- "environments/**"
- "modules/**"
permissions:
contents: read
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
TF_VERSION: "1.7.0"
jobs:
apply-production:
name: Apply Production Changes
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: environments/production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init
- name: Terraform Apply
run: terraform apply -auto-approve
- name: Capture Applied State
if: always()
run: |
echo "## Applied Resources" >> $GITHUB_STEP_SUMMARY
terraform show -no-color >> $GITHUB_STEP_SUMMARY
The environment: production line supports protection rules — you can require manual approval before the apply step runs, even though the merge already happened. This gives you a second gate: the PR review ensures the change is correct, and the environment approval ensures the timing is right.
Step 6: Add a Security Scanning Step
Before the plan runs, scan your Terraform for common security misconfigurations:
- name: Security Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: "config"
scan-ref: "environments/production"
format: "table"
exit-code: "1"
severity: "HIGH,CRITICAL"
Add this step before the Terraform Plan step in your CI workflow. If Trivy finds a high or critical security issue, the pipeline fails and the PR can’t be merged until the issue is fixed. This catches the kind of misconfiguration that’s easy to miss in manual reviews — like forgetting to enable encryption on a storage account or leaving SSH open to the entire internet. Our knowledge base covers automated retraining pipelines if you want to dig into the technical side.
The Complete Workflow in Practice
Monday morning: the IT contractor needs to add a new inbound rule to allow traffic from a partner’s IP address. Instead of logging into the Azure portal, they clone the infrastructure repository, create a branch, and edit the Terraform configuration:
security_rule {
name = "AllowPartnerAPI"
priority = 300
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "8443"
source_address_prefix = "198.51.100.0/24"
destination_address_prefix = "*"
}
They push the branch and open a pull request. Within two minutes, the CI pipeline runs, posts a plan showing “1 to add” with the specific rule, and the security scan confirms no issues.
The business owner reviews the PR. The plan clearly shows what’s changing. They approve and merge.
Monday 3:02 PM: the CD pipeline applies the change. The new firewall rule is live. The entire change is documented in Git — who requested it, who approved it, when it was applied, and exactly what changed.
Three months later: someone asks “why is port 8443 open to that IP range?” They search the Git history and find the pull request with the full context. No mystery. No guessing. No calling the contractor who may or may not remember.
Handling Terraform State Safely
Store your state in a remote backend with locking. For Azure, this means an Azure Storage Account:
az group create --name rg-terraform-state --location eastus
az storage account create \
--name stterraformstate001 \
--resource-group rg-terraform-state \
--sku Standard_LRS \
--encryption-services blob
az storage container create \
--name tfstate \
--account-name stterraformstate001
The storage account should be in a separate resource group from your managed infrastructure. Enable versioning on the storage container — if the state file is corrupted, you can roll back to a previous version.
Extending the Pipeline: Notifications and Drift Detection
Slack or Teams notifications when the pipeline runs:
- name: Notify Team
if: always()
uses: slackapi/[email protected]
with:
payload: |
{
"text": "Infrastructure deployment ${{ job.status }}: ${{ github.event.head_commit.message }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Drift detection runs the plan on a schedule, even when no changes have been submitted. This catches manual changes — someone logged into the portal and modified a security group directly, bypassing the CI/CD pipeline:
# .github/workflows/drift-detection.yml
name: Infrastructure Drift Detection
on:
schedule:
- cron: "0 6 * * 1" # Every Monday at 6 AM UTC
jobs:
check-drift:
runs-on: ubuntu-latest
defaults:
run:
working-directory: environments/production
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- name: Terraform Init
run: terraform init
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
- name: Check for Drift
id: drift
run: |
terraform plan -detailed-exitcode -no-color > plan_output.txt 2>&1
EXIT_CODE=$?
if [ $EXIT_CODE -eq 2 ]; then
echo "drift_detected=true" >> $GITHUB_OUTPUT
echo "DRIFT DETECTED - infrastructure differs from configuration"
cat plan_output.txt
elif [ $EXIT_CODE -eq 0 ]; then
echo "drift_detected=false" >> $GITHUB_OUTPUT
echo "No drift detected"
else
echo "Plan failed"
cat plan_output.txt
exit 1
fi
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
- name: Alert on Drift
if: steps.drift.outputs.drift_detected == 'true'
run: |
echo "::warning::Infrastructure drift detected! Review plan output above."
The -detailed-exitcode flag on terraform plan returns exit code 2 when changes are detected. This is how the script distinguishes between “no changes needed” (exit 0), “changes detected” (exit 2), and “plan failed” (exit 1).
FAQ
What is CI/CD for infrastructure?
CI/CD for infrastructure applies the same continuous integration and continuous deployment practices used in software development to infrastructure changes. Instead of manually configuring servers and network devices, you define infrastructure as code in configuration files, submit changes through pull requests for review, and let automated pipelines validate and apply the changes.
Can non-software companies use CI/CD?
Yes. Any organization that manages IT infrastructure — servers, networks, cloud resources, security policies — can benefit from CI/CD practices. You don’t need software developers on staff. The workflows use configuration files rather than programming languages, and GitHub Actions provides the automation platform with no infrastructure to manage.
What is GitHub Actions?
GitHub Actions is a CI/CD platform built into GitHub that automates workflows based on repository events. When you push code, open a pull request, or merge changes, GitHub Actions runs workflows you define in YAML files. These workflows can validate Terraform configurations, run security scans, execute deployment scripts, and notify your team — all automatically.
Do I need to know how to code to use Terraform and GitHub Actions?
You need basic familiarity with text files and configuration syntax, but you don’t need programming experience. Terraform uses a declarative language called HCL that reads more like a configuration file than code. GitHub Actions workflows are defined in YAML, which is a structured text format. Both are learnable in a few days with practical exercises.
How much does GitHub Actions cost for infrastructure CI/CD?
GitHub Actions is free for public repositories and includes 2,000 minutes per month for private repositories on the Free plan. The Team plan at $4 per user per month includes 3,000 minutes. For most small business infrastructure workflows that run a few times per week, the free tier is sufficient.