All Posts Automation

CI/CD for Non-Software Companies: Automating Your Infrastructure Deployments

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.

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.

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.