Configurations I recommend you setup to deploy your Terraform into Azure at scale using GitHub Actions

This blog post delves into the world of Terraform deployments in Microsoft Azure and explores how GitHub Actions can be harnessed to streamline the process, particularly when dealing with larger-scale deployments across various terraform resources, components and environments. We’ll walk through the essential configurations and best practices that I recommend for deploying Terraform at scale with Azure using GitHub Actions.

This post is part of the Azure Back to School event – certainly check it out, lots of awesome content available: https://azurebacktoschool.github.io/ – I want to extend my heartfelt thanks to Dwayne Natwick for tirelessly organizing this event year after year!

What will we cover?

I will split this blog post into multiple sections and will cover:

  1. Structuring your GitHub Repository
  2. Standard GitHub Repository recommended settings
  3. GitHub Repository Environment Secrets and Variables
  4. GitHub Action Setup
  5. GitHub Action Templating
  6. Using matrixes to deploy multiple components to multiple environments within GitHub Actions
  7. Dependabot Setup

What will be deployed?

The basis of this post is to show configurations I recommend you setup to deploy your Terraform into Azure at scale using GitHub Actions, the Terraform that will be deployed is example purpose only, but we will be deploying:

An Azure Platform consisting of the below, deployed into two environments development & production:

  • core: A resource group
  • logging: Log Analytics workspace
  • networking: Virtual network and private dns zones

This is show the recommended folder structure, utilising this will scale to multiple more environments..

1. Structuring your GitHub Repository

Creating a well-organized folder structure for your Terraform GitHub repository is essential for maintaining a clean and manageable codebase. Below is an example folder structure that I recommend, which you can adapt further to your needs:

my-terraform-repo/
├── .github/
│   └── workflows/
│       └── deploy.yaml                  # GitHub Actions core deployment workflow file
│       └── template-terraform.yaml      # GitHub Actions template workflow file for terraform
│       └── dependabot.yaml              # Dependabot yaml
├── environments/
│   ├── development/
│   │   ├── development.tfvars           # Development environment .tfvars file
│   ├── production/
│   │   ├── main.tf                      # Development environment configuration
├── modules/
│   ├── log_analytics/                   # Log analytics module
│   │   ├── init.tf          
│   │   ├── log_analytics_solutions.tf   
│   │   ├── log_analytics.tf     
│   │   ├── outputs.tf     
│   │   ├── variables.tf     
│   ├── private_dns_zone/                # Private dns zone module
│   │   ├── init.tf           
│   │   ├── private_dns_zone.tf  
│   │   ├── outputs.tf     
│   │   ├── variables.tf     
│   ├── virtual_network/                 # Virtual network module
│   │   ├── init.tf           
│   │   ├── virtual_network.tf     
│   │   ├── subnets.tf   
│   │   ├── outputs.tf   
│   │   ├── variables.tf    
├── platform/                            # Folder containing the required deployments for the Azure environment
│   ├── core/                            # Containing the core components
│   │   ├── data.tf                      # Data references needed for the component
│   │   ├── main.tf                      # Configuration of the component
│   │   ├── providers.tf                 # Relevant Providers
│   │   ├── variables.tf                 # Variables specific to the component
│   ├── logging/                         # Containing the logging components
│   │   ├── data.tf                      # Data references needed for the component
│   │   ├── main.tf                      # Configuration of the component
│   │   ├── providers.tf                 # Relevant Providers
│   │   ├── variables.tf                 # Variables specific to the component
│   ├── network/                         # Containing the network components
│   │   ├── data.tf                      # Data references needed for the component
│   │   ├── main.tf                      # Configuration of the component
│   │   ├── providers.tf                 # Relevant Providers
│   │   ├── variables.tf                 # Variables specific to the component
├── README.md                            # Project documentation

Here’s an explanation of the key components in this folder structure:

  • .github/workflows/ : This directory contains your GitHub Actions workflow files for deploying your automated Terraform deployments.
  • environments/ : This directory holds subdirectories for each environment (e.g., development, production). Each environment directory contains its specific Terraform configuration .tfvars files
  • modules/ : In this directory, you define reusable Terraform modules for creating infrastructure components. Modules encapsulate resource definitions, variables, and outputs.
  • platform/ : Contains the relevant components required to deploy the Azure environment successfully

This structure helps you maintain a clean and organised Azure Terraform project, making it easier to collaborate, manage different environments, and scale your infrastructure as needed. Remember that you can adapt this structure to fit your specific project requirements.

2. Standard GitHub Repository recommended settings

Enable a branch protection policy for your repository, branch protection policies in GitHub are a set of rules and settings that control what actions can be performed on a branch and under what conditions. These policies are essential for maintaining code quality, security, and collaboration in your repository.

Branch protection policies can really vary betewen organisations and teams, I recommend checking out this article further for you to decide on which settings are best for your branch protection policy – https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches

In this I will enable the setting Require a pull request before merging

3. GitHub Repository Environment Secrets and Variables

Deploying between environments, we need to have a way to potentially use different secrets and variables between these, along with this we want to streamline deployments while enhancing security. Time to explore how leveraging these GitHub features can benefit your Terraform deployments.

GitHub Repository Environment Secrets are encrypted variables that you can store at the repository level. These secrets are designed to securely store sensitive information like API keys, credentials, or other private data your Terraform code requires during deployment within GitHub Actions. While these secrets can be utilised in your Terraform deployments, it’s often advisable to reserve Azure Key Vault for Terraform-specific secrets, leveraging them through data key_vault_secret blocks for added security and ease of management.

GitHub Repository Environment Variables complement secrets by providing a way to manage non-sensitive configuration settings.

For multi-environment projects (e.g., development, staging, production), environment-specific variables streamline configurations. You can set variables to change based on the target environment, making deployment across different stages seamless.

Now that you understand the use of both environment secrets and variables, let’s see how to implement these features in your Terraform workflow:

1. Storing Secrets

  1. Navigate to your GitHub repository.
  2. Click on “Settings” at the top of the repository.
  3. In the left sidebar, click on “Secrets.”
  4. Click “New repository secret” to add your secret. Give it a name and value.
  5. In your GitHub Actions workflow, reference the secret using ${{ secrets.YOUR_SECRET_NAME }}.

2. Using Variables

  1. In your GitHub repository, go to “Settings.”
  2. In the left sidebar, click on “Environment.”
  3. Click on the environment for which you want to define variables.
  4. Click “Add environment variable” and provide a name and value.
  5. In your GitHub Actions workflow, access the variable using ${{ env.VARIABLE_NAME }}.

3. Incorporating into GitHub Actions

In your GitHub Actions workflow files, use the ${{ secrets.YOUR_SECRET_NAME }} syntax for secrets and ${{ env.VARIABLE_NAME }} for variables wherever needed.

What are they used for as part of this?

I recommend using Github repository secrets to store the relevant secrets that the GitHub Workflow requires, which are:

  • CLIENT_ID: Client ID of the Azure AD Service Principal that will be used to deploy the Terraform
  • CLIENT_SECRET: Client Secret of the Azure AD Service Principal that will be used to deploy the Terraform
  • DEPLOYMENT_SUBSCRIPTION_ID: Azure subscription ID of where the Terraform storage account is situated to store the .tfstate file
  • SUBSCRIPTION_ID: Azure subscription ID of where the Terraform code will be deployed to
  • TENANT_ID: Azure AD tenant ID

4. GitHub Action Setup

Lets review the core GitHub Action ( .github/workflows/deploy.yaml )

name: 'Terraform Deploy'

on:
  push:
    branches:
    - main
  pull_request:
 
jobs:
  terraform:
    uses: ./.github/workflows/template-terraform.yaml
    strategy:
      matrix:
        environment: ['development','production']
        platform_directory: ['core','logging','network']
      fail-fast: true
      max-parallel: 1
    with:
      environment: ${{ matrix.environment }}
      backend_tf_rg: thomasthorntoncloud
      backend_tf_sa: thomasthorntontfstate
      backend_tfstate_name: ${{ matrix.environment }}-tf-github${{ matrix.platform_directory }}
      tfstate_container: github-tf-at-scale-${{ matrix.environment }}
      platform_directory: ${{ matrix.platform_directory }}
    secrets:
      CLIENT_ID: ${{ secrets.CLIENT_ID }}
      CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
      SUBSCRIPTION_ID: ${{ secrets.SUBSCRIPTION_ID }}
      TENANT_ID: ${{ secrets.TENANT_ID }}
      DEPLOYMENT_SUBSCRIPTION_ID: ${{ secrets.DEPLOYMENT_SUBSCRIPTION_ID }}

If we look. at the above, its relatively lightweight but very effective, which I will discuss by looking at the key areas of the pipeline:

  • Lines 3-7 :  The trigger for the action is when code is pushed to the “main” branch or when a pull request is opened.

Screenshot below shows the steps during a pull request, notice Terraform Apply is skipped. as mentioned above

  • Line 9-11 : Beginning. ofthe terraform job that uses a template file (more on that below)
  • Lines 12-17 : Using a series of strategy matrixes, these are awesome! The job is run with a matrix strategy that defines different values for the “environment” and “platform_directory” variables. The “fail-fast” and “max-parallel” options are also set. (section on that below)
  • Lines 18-30 : Mixture of concatenated variables and secrets being used from the created GitHub repository secrets, notice line 19 determines the environment? By this, it decides to use either the development or production environment secrets. The “with” section defines the input variables for the custom action. The “environment” and “platform_directory” variables are set based on the matrix strategy. Other variables, such as the name of the Terraform state file and the name of the storage container, are also set. The “secrets” section defines the secrets that are required for the deployment, such as the client ID, client secret, subscription ID, and tenant ID. These secrets are stored securely in GitHub and are not visible in the code.

5. GitHub Action Templating

GitHub Action Templating involves creating reusable templates for your workflows. These templates encapsulate common actions, steps, and configuration settings that you can apply across various repositories or workflows. Instead of rewriting the same workflow code repeatedly, you define it once in a template and reuse it wherever needed. On the templating topic, I did write a similar post sometime ago for Azure DevOps – Creating templates in Azure DevOps Pipelines

As your Terraform projects grow in scale and complexity, so do your automation needs. Templating simplifies the management of intricate workflows by breaking them down into modular, reusable components. A few benefits as to why I recommend using templates in GitHub Actions:

  • Consistency and standardisation : Consistency is key in automation. Templating ensures that your workflows adhere to a predefined standard. Whether you have a single repository or multiple projects, you can maintain uniformity in your CI/CD processes.
  • Increases Efficiency and Productivity : With templating, you avoid redundant code. This not only saves time but also reduces the likelihood of errors. You can swiftly create, modify, and deploy workflows, boosting productivity.
  • Easier to maintain : When you need to update your automation logic, a single change to the template propagates to all workflows using it. This centralised management simplifies maintenance and minimises the risk of overlooking critical updates.
  • Reusability : Once you create a template, you can reuse it across multiple repositories.

With this GitHub repository – I created a template to deploy terraform with, used by multiple components and environments all deployed from the same location. ( .github/workflows/template-terraform.yaml )

name: Reusable workflow example

on:
  workflow_call:
    inputs:
      backend_tfstate_name:
        required: true
        type: string
      environment:
        required: true
        type: string
      backend_tf_rg:
        required: true
        type: string
      backend_tf_sa:
        required: true
        type: string
      platform_directory:
        required: true
        type: string
      tfstate_container:
        required: true
        type: string
    secrets:
      CLIENT_ID:
        required: true
      CLIENT_SECRET:
        required: true
      SUBSCRIPTION_ID:
        required: true
      TENANT_ID:
        required: true
      DEPLOYMENT_SUBSCRIPTION_ID:
        required: true


jobs:
  deploy-terraform:
    name: ${{ inputs.platform_directory }}
    environment: ${{ inputs.environment }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
 
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: 1.5.3
          terraform_wrapper: false

      - name: Terraform Init
        id: init
        run: terraform init -backend-config="resource_group_name=${{ inputs.backend_tf_rg }}" -backend-config="storage_account_name=${{ inputs.backend_tf_sa }}" -backend-config="container_name=${{ inputs.tfstate_container }}" -backend-config="key=${{ inputs.backend_tfstate_name }}.tfstate"
        env:
          ARM_CLIENT_ID: ${{ secrets.CLIENT_ID }}
          ARM_CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.SUBSCRIPTION_ID }}
          ARM_TENANT_ID: ${{ secrets.TENANT_ID }}
        working-directory: ./platform/${{ inputs.platform_directory }}
        shell: bash
          
      - name: Terraform Plan  
        id: plan
        run: terraform plan -no-color -input=false -var deployment_subscription_id=$DEPLOYMENT_SUBSCRIPTION_ID -var-file="../../environments/${{ inputs.environment }}/${{ inputs.environment }}.tfvars"
        env:
          ARM_CLIENT_ID: ${{ secrets.CLIENT_ID }}
          ARM_CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.SUBSCRIPTION_ID }}
          ARM_TENANT_ID: ${{ secrets.TENANT_ID }}
          DEPLOYMENT_SUBSCRIPTION_ID: ${{ secrets.DEPLOYMENT_SUBSCRIPTION_ID }}
        working-directory: ./platform/${{ inputs.platform_directory }}
        shell: bash
        continue-on-error: false

      - name: Terraform Apply  
        id: apply
        run: terraform apply -auto-approve -var deployment_subscription_id=$DEPLOYMENT_SUBSCRIPTION_ID -var-file="../../environments/${{ inputs.environment }}/${{ inputs.environment }}.tfvars"
        if: github.ref == 'refs/heads/main'
        env:
          ARM_CLIENT_ID: ${{ secrets.CLIENT_ID }}
          ARM_CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.SUBSCRIPTION_ID }}
          ARM_TENANT_ID: ${{ secrets.TENANT_ID }}
          DEPLOYMENT_SUBSCRIPTION_ID: ${{ secrets.DEPLOYMENT_SUBSCRIPTION_ID }}
        working-directory: ./platform/${{ inputs.platform_directory }}
        shell: bash
        continue-on-error: false

Key Points in the above pipeline

  • Workflow Name and Trigger: This workflow is named “Reusable workflow example” and is triggered by a workflow_call event.
  • Input Parameters: This workflow accepts several input parameters, including backend_tfstate_name, environment, backend_tf_rg, backend_tf_sa, platform_directory, and tfstate_container. All of these inputs are required and have specified types and all all inputted from the core pipeline ( .github/workflows/deploy.yaml )
  • Secrets: This workflow requires several secrets, including CLIENT_ID, CLIENT_SECRET, SUBSCRIPTION_ID, TENANT_ID, and DEPLOYMENT_SUBSCRIPTION_ID. These secrets are essential for authenticating and interacting with Azure services.
  • Jobs: The workflow contains a single job named “deploy-terraform.” This job will run on an ubuntu-latest runner.
  • Steps: Within the job, there are multiple steps that set up the environment for Terraform, initialize it, perform a plan, and apply changes if the branch is ‘main’. These steps ensure that Terraform is configured and run correctly for the specified environment.
    • The workflow starts by checking out the repository (actions/checkout@v2).
    • Then, it sets up Terraform using the hashicorp/setup-terraform@v2 action.
    • The Terraform Init step initializes Terraform with the specified backend configuration.
    • The Terraform Plan step creates an execution plan.
    • The Terraform Apply step applies changes to the infrastructure but only if the branch is ‘main’.

Each step is configured with the necessary environment variables and working directories to ensure that Terraform operates correctly for the specified platform and environment.

6. Using matrixes to deploy multiple components to multiple environments within GitHub Actions

GitHub Actions offers a feature known as matrixes to address the complexities of multi-environment deployments.

A matrix strategy lets you use variables in a single job definition to automatically create multiple job runs that are based on the combinations of the variables. For example, you can use a matrix strategy to test your code in multiple versions of a language or on multiple operating systems.

https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs

A matrix in GitHub Actions is a mechanism that allows you to run jobs or workflows with multiple configurations concurrently. This is invaluable when you need to deploy various components across different environments, each with its specific parameters. In this case, multiple Terraform components across various environments

Diving further into the matrix being used in this workflow:

    strategy:
      matrix:
        environment: ['development','production']
        platform_directory: ['core','logging','network']
      fail-fast: true
      max-parallel: 1

I have leveraged the matrix variables to customise my Terraform deployments for each component-environment combination.

With the above, the key pieces:

  • Matrix Variables: The matrix block defines two matrix variables: environment and platform_directory. These variables are used to create combinations of values for parallel job execution.

This configuration creates a matrix with six combinations: [development, core], [development, logging], [development, network], [production, core], [production, logging], and [production, network].

  • Parallel Job Execution: The max-parallel option is set to 1, which means that only one combination of the matrix values will run in parallel at a time. This is useful for scenarios where you want to limit the number of concurrent deployments, ensuring that they do not overwhelm resources.
  • The fail-fast option is set to true. If any job within the matrix fails, the entire matrix job will be marked as failed. This can help catch and address issues early in the deployment process.
  • This matrix configuration is designed to execute Terraform deployments for different combinations of environments and platform directories in a controlled and sequential manner, with a focus on minimising parallel execution and ensuring early detection of failures.

GitHub Actions matrixes are a powerful tool for managing multi-environment and multi-configuration Terraform deployments efficiently. They enable parallelised deployments, promote consistency, and simplify workflow configurations. With matrixes, you can embrace the complexity of modern infrastructure management while keeping your deployment process organized and efficient. By harnessing the power of matrices in GitHub Actions, you can optimise your Terraform deployments and streamline your infrastructure as code workflows.

7. Dependabot Setup

Dependabot enforces consistency across your Terraform codebase by applying updates consistently across all parts of your project. This prevents issues that can arise from inconsistent or mismatched dependencies.

Dependabot can be integrated into GitHub Actions to automate the dependency update process. To enable Dependabot on GitHub:

  1. Go to the “Security” tab of your GitHub repository.
  2. Under “Code Security and analysis
  3. Choose “Dependabot” as the provider.
  4. Configure the settings for your repository in Dependabot version updates & enable Dependabot security updates

Use this Dependabot config:

version: 2
updates:
  - package-ecosystem: "terraform" # See documentation for possible values
    directory: "/modules/log_analytics"
    schedule:
      interval: "weekly"
  - package-ecosystem: "terraform" # See documentation for possible values
    directory: "/modules/private_dns_zone"
    schedule:
      interval: "weekly"
  - package-ecosystem: "terraform" # See documentation for possible values
    directory: "/modules/virtual_network"
    schedule:
      interval: "weekly"
  - package-ecosystem: "github-actions"
    directory: "/.github/workflows"
    schedule:
      interval: "weekly"

This configuration automates dependency management for Terraform modules in different subdirectories, each scheduled for weekly updates. Additionally, it manages GitHub Actions workflows in the “/.github/workflows” directory, also scheduled for weekly updates. Dependabot will regularly check for and create pull requests to update dependencies in these specified directories.

Rounding up

Thank you for reading and hopefully using this blog post to assist you and your journey of deploying into Azure at scale using Terraform and GitHub Actions. There is lots of various settings and configurations you can use, this is the basis to start you on your journey.

The complete repository is found here and as always, any questions – do reach out to me