Efficient Azure Infrastructure Management with Terraform and GitLab CI/CD: A DevOps Approach
Introduction
In today's fast-paced DevOps world, managing infrastructure efficiently and securely is crucial.
This article delves into our sophisticated approach to deploying Azure infrastructure using Terraform, integrated within a GitLab CI/CD pipeline and a centralized terraform.tfstate on Azure. We aim to provide a high-level understanding, supplemented with code examples, to demonstrate how this process enables precise control and streamlined workflows for managing different environments such as development, quality assurance, and production.
Strategic Approach: Unification, Security, and Centralization
-
Unification of Infrastructure Code by keeping the code for development, staging, and production environments in a single GitLab project to simplify management and maintenance.
Security and Control in Deployment by integrating code control tasks in a CI/CD pipeline contributes to enhancing the security and quality of infrastructure deployment.
Centralization of
terraform.tfstate
files in Azure Storage for better state management and facilitates collaboration among teams.
The strategy is to ensure that changes made in one environment do not inadvertently affect the others.
To begin with, you need to separate environments into specific folders. Each of these folders will contain its own Terraform code. This can lead to duplicate code, which you can overcome by using terraform modules.
Modifications to an environment must be made in a single, dedicated branch.
This is achieved by creating branches specific to each environment, with the CI pipeline executing Terraform commands (validate
, plan
, apply
) only for the modified environment. This method requires a comprehensive understanding of the infrastructure and precise code management.
The pipeline will be triggered only when merging from a branch to the master.
Implementation Details: Setting Up the CI/CD Pipeline
First of all, you'll need a storage container to store your tfstate centrally. This is achieved via the provider.tf. In the example below, we're using variables declared via the CI/CD part of Gitlab.
TF_VAR_ARM_SUBSCRIPTION_ID
TF_VAR_ARM_CLIENT_ID
TF_VAR_ARM_CLIENT_SECRET
The values of these variables will be transmitted to terraform via the .gitlab-ci.yml file.
This snippet establishes the foundation for using Terraform with Azure. It specifies the required Terraform and Azure provider versions and configures the backend using Azure Storage. This setup is crucial for centralized state management, ensuring that the terraform.tfstate
file is stored securely and is accessible for collaborative work. The use of variables (var.ARM_SUBSCRIPTION_ID
, etc.) allows for dynamic configuration, enhancing security and flexibility in managing different Azure resources.
Example of a provider.tf
terraform {
required_version = ">=1.2"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "3.74"
}
}
backend "azurerm" {
resource_group_name = "existingstorageresourcegroup"
storage_account_name = "existingstorageaccount"
container_name = "existingstoragecontainer"
key = "projectname.tfstate"
}
}
provider "azurerm" {
subscription_id = var.ARM_SUBSCRIPTION_ID
client_id = var.ARM_CLIENT_ID
client_secret = var.ARM_CLIENT_SECRET
}
The .gitlab-ci.yml
file defines the CI/CD pipeline in GitLab, crucial for automating the Terraform deployment process. It outlines various stages like terraform-validate
, terraform-plan
, and terraform-apply
, each tailored to execute specific Terraform commands.
The use of conditional rules based on GitLab events and branch names ensures that changes in specific environments (like DEV) are isolated and managed effectively. This setup provides a structured and automated approach to infrastructure deployment, minimizing human errors and enhancing efficiency.
You'll see that we force only changes in DEV's environment to be taken into account. Simply duplicate this example with the desired environments, such as qas, prd, sandbox, etc.
Example of a .gitlab-ci.yml
variables:
TF_VAR_ARM_SUBSCRIPTION_ID : ${ARM_SUBSCRIPTION_ID} #Will be used in TF codes
TF_VAR_ARM_CLIENT_ID : ${ARM_CLIENT_ID} #Will be used in TF codes
TF_VAR_ARM_CLIENT_SECRET : ${ARM_CLIENT_SECRET} #Will be used in TF codes
TF_VAR_TF_STATE_NAME : ${TF_STATE_NAME} #Will be used in TF codes
DEV_FOLDER : dev
TF_ROOT : ${CI_PROJECT_DIR} # The relative path to the root directory of the Terraform project
TF_STATE_NAME: default # The name of the state file used by the GitLab Managed Terraform state backend
image:
name: "registry.gitlab.com/gitlab-org/terraform-images/stable:latest"
cache:
key: "${TF_ROOT}"
paths:
- ${TF_ROOT}/.terraform/
stages:
- terraform-validate
- terraform-plan
- terraform-apply
DEV:terraform-validate:
tags:
- internal
stage: terraform-validate
script:
- gitlab-terraform validate
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
changes:
- "${DEV_FOLDER}/**/*"
- if: '$CI_PIPELINE_SOURCE != "merge_request_event" && $CI_COMMIT_REF_NAME == "master" && $CI_OPEN_MERGE_REQUESTS == null'
changes:
- "${DEV_FOLDER}/**/*"
when: never
environment:
name: ${TF_VAR_TF_STATE_NAME}
action: prepare
variables:
TF_ROOT: ${CI_PROJECT_DIR}/${DEV_FOLDER}/
DEV:terraform-plan:
tags:
- internal
stage: terraform-plan
script:
- gitlab-terraform plan
- gitlab-terraform plan-json
resource_group: ${TF_STATE_NAME}
artifacts:
public: false
paths:
- ${TF_ROOT}/plan.cache
reports:
terraform: ${TF_ROOT}/plan.json
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
changes:
- "${DEV_FOLDER}/**/*"
- if: '$CI_PIPELINE_SOURCE != "merge_request_event" && $CI_COMMIT_REF_NAME == "master" && $CI_OPEN_MERGE_REQUESTS == null'
changes:
- "${DEV_FOLDER}/**/*"
when: never
environment:
name: ${TF_VAR_TF_STATE_NAME}
action: prepare
variables:
TF_ROOT: ${CI_PROJECT_DIR}/${DEV_FOLDER}/
DEV:terraform-apply:
tags:
- internal
stage: terraform-apply
script:
- gitlab-terraform apply
resource_group: ${TF_STATE_NAME}
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master" && $TF_AUTO_DEPLOY == "true"'
changes:
- "${DEV_FOLDER}/**/*"
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
changes:
- "${DEV_FOLDER}/**/*"
when: manual
environment:
name: ${TF_VAR_TF_STATE_NAME}
action: prepare
variables:
TF_ROOT: ${CI_PROJECT_DIR}/${DEV_FOLDER}/
All you need now is some terraform code in main.tf to test the deployment of the infrastructure.
Execution
In GitLab create a new dev branch from the master and then commit some terraform code in it.
You can get a very simple azurerm_resource_group below and add it in the main.tf file in the dev folder of the dev branch
resource "azurerm_resource_group" "rg" {
name = "myTFResourceGroup"
location = "westus2"
}
Start a new merge request and follow the pipeline execution
Quality and security improvement
To improve the quality and security of the infrastructure, we're going to add jobs to check the code before it is executed by terraform.
We are creating a new code-review stage in which we will add 3 jobs
gitleaks
checkov
terrascan
All these modifications must be added to the .gitlab-ci.yml file.
stages:
- code-review
- terraform-validate
- terraform-plan
- terraform-apply
The gitleaks Job definition
.gitleaks:
stage: gitleaks
allow_failure: false
image:
name: zricethezav/gitleaks:latest
entrypoint: [""]
# We add the "--no-git" opton to avoid scanning the entire commit history.
# The aim is to alert you to the presence of sensitive information.
script: gitleaks detect --no-git -v -c .gitleaks.toml ${CI_PROJECT_DIR}/${FOLDER}/ --exit-code 0
The checkov Job definition
.checkov:
stage: checkov
allow_failure: true # True for AutoDevOps compatibility
image:
name: bridgecrew/checkov:latest
entrypoint:
- '/usr/bin/env'
- 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
tags: ["internal"]
rules:
- if: $SAST_DISABLED
when: never
- if: $CI_COMMIT_BRANCH
exists:
- '**/*.yml'
- '**/*.yaml'
- '**/*.json'
- '**/*.template'
- '**/*.tf'
- '**/serverless.yml'
- '**/serverless.yaml'
script:
- script -q -c 'checkov -d . ; echo $? > CKVEXIT'
- exit $(cat CKVEXIT)
The terrascan Job definition
variables:
TERRASCAN_TAG: "1.18.1"
.terrascan:
image:
name: tenable/terrascan:${TERRASCAN_TAG}
entrypoint: ["/bin/sh", "-c"]
tags: ["internal"]
script:
- /go/bin/terrascan scan -v -i terraform -d ${CI_PROJECT_DIR}/${FOLDER}/ -o json
# You can add the option --skip-rules to bybass false positive errors
# --skip-rules="AC_AZURE_0356,AC_AZURE_0389,AC_AZURE_0185,AC_AZURE_0169"
Add the jobs for the dev environment, before the terraform jobs.
DEV:gitleaks:
stage: code-review
extends: .gitleaks
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
changes:
- "${DEV_FOLDER}/**/*"
variables:
FOLDER: ${DEV_FOLDER}
DEV:checkov:
stage: code-review
extends: .checkov
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
changes:
- "${DEV_FOLDER}/**/*"
variables:
FOLDER: ${DEV_FOLDER}
DEV:terrascan:
stage: code-review
extends: .terrascan
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
changes:
- "${DEV_FOLDER}/**/*"
- if: '$CI_PIPELINE_SOURCE != "merge_request_event" && $CI_COMMIT_REF_NAME == "master" && $CI_OPEN_MERGE_REQUESTS == null'
when: never
changes:
- "${DEV_FOLDER}/**/*"
variables:
FOLDER: ${DEV_FOLDER}
There's plenty of room for improvement in these examples, at every level, but the aim is to demonstrate simple use, with separation by environment type in sub-folders of the same project.
Final Thoughts: Mastering Azure Infrastructure with Terraform and GitLab CI/CD
In summary, integrating Terraform with GitLab CI/CD for Azure infrastructure management offers a sophisticated and efficient approach, blending centralized state management, streamlined workflow, and enhanced security.
By unifying infrastructure code across various environments in a single GitLab project, and incorporating robust code control within the CI/CD pipeline, this method not only boosts collaboration and control but also ensures compliance with evolving industry standards.
Embracing this strategy represents a significant leap in modern infrastructure management, striking a balance between agility, security, and maintainability.
Note 1 : AI makes mistakes
The cover image for this article was generated by AI, which inadvertently misspelled 'Azure' as 'Azrure'. We appreciate your understanding and hope this minor error doesn't detract from the key insights of our article on managing Azure infrastructure with Terraform and GitLab CI/CD."
Note 2 : Terraform's Licensing Changes and Introduction to OpenTofu:
It's important to be aware of the recent changes in Terraform's licensing model. These changes may affect how Terraform can be used in your projects, especially in larger-scale or commercial environments. We advise staying updated with Terraform's licensing terms to ensure compliance.
As a response to these licensing changes, the DevOps community has seen the emergence of solutions like OpenTofu. OpenTofu is designed as a workaround to these licensing issues, providing a similar functionality to Terraform but under a different licensing model. It's worth exploring OpenTofu as an alternative, particularly for those who are looking for more flexible licensing terms while maintaining the efficiency and reliability in cloud infrastructure management.