CI/CD with Terraform
Introduction
Continuous Integration and Continuous Deployment (CI/CD) has revolutionized how we deliver software. Similarly, Infrastructure as Code (IaC) has transformed how we manage infrastructure. When these two powerful approaches are combined, we can automate both application and infrastructure deployments in a consistent, repeatable way.
In this guide, we'll explore how Terraform, a popular IaC tool, can be integrated into CI/CD pipelines to automate infrastructure provisioning and management. By the end, you'll understand how to set up a pipeline that automatically plans, applies, and tests infrastructure changes whenever your code is updated.
What is Terraform?
Terraform is an open-source IaC tool created by HashiCorp that allows you to define and provision infrastructure using a declarative configuration language called HashiCorp Configuration Language (HCL). With Terraform, you can:
- Define infrastructure as code in configuration files
- Version control your infrastructure
- Create reusable infrastructure modules
- Manage infrastructure across multiple cloud providers
Why Use Terraform in CI/CD?
Integrating Terraform into your CI/CD pipeline offers several benefits:
- Consistency: Infrastructure is deployed the same way every time
- Automation: Reduces manual steps in infrastructure deployment
- Version Control: Infrastructure changes are tracked alongside application code
- Testing: Infrastructure can be tested before deployment to production
- Visibility: Changes to infrastructure are visible to the entire team
Basic Terraform Concepts
Before diving into CI/CD integration, let's review some key Terraform concepts:
Terraform Files
A basic Terraform project typically consists of these files:
main.tf
- Contains the main set of configuration for your projectvariables.tf
- Contains variable declarationsoutputs.tf
- Contains outputs from resourcesterraform.tfvars
- Contains variable definitions (values).terraform.lock.hcl
- Lock file for provider versionsbackend.tf
- Configures where Terraform state is stored
Terraform Workflow
The standard Terraform workflow includes these commands:
terraform init
- Initialize a working directoryterraform plan
- Preview changes before applyingterraform apply
- Apply the changesterraform destroy
- Destroy provisioned infrastructure
Setting Up Terraform for CI/CD
Step 1: Store Terraform State Remotely
For CI/CD, Terraform state should be stored remotely so it's accessible to the pipeline:
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "terraform.tfstate"
region = "us-west-2"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Step 2: Structure Your Project
Organize your Terraform code into modules for reusability:
project/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ └── terraform.tfvars
│ └── prod/
│ ├── main.tf
│ └── terraform.tfvars
└── modules/
├── networking/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── compute/
├── main.tf
├── variables.tf
└── outputs.tf
Step 3: Implement a Sample Infrastructure
Here's a simple example of a Terraform configuration for AWS:
# main.tf
provider "aws" {
region = var.region
}
module "vpc" {
source = "../modules/networking"
vpc_cidr = var.vpc_cidr
environment = var.environment
}
module "ec2_instance" {
source = "../modules/compute"
subnet_id = module.vpc.subnet_id
instance_type = var.instance_type
environment = var.environment
}
# variables.tf
variable "region" {
description = "AWS region to deploy resources"
type = string
default = "us-west-2"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "environment" {
description = "Deployment environment"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
Integrating Terraform with CI/CD Pipelines
Now, let's explore how to integrate Terraform into various CI/CD platforms:
GitHub Actions
Here's a simple GitHub Actions workflow for Terraform:
# .github/workflows/terraform.yml
name: "Terraform CI/CD"
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
terraform:
name: "Terraform"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Terraform Init
run: terraform init
- name: Terraform Format
run: terraform fmt -check
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: terraform plan
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve
GitLab CI/CD
For GitLab, you can use the following configuration:
# .gitlab-ci.yml
stages:
- validate
- plan
- apply
image:
name: hashicorp/terraform:latest
entrypoint:
- '/usr/bin/env'
- 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
before_script:
- terraform init
validate:
stage: validate
script:
- terraform validate
- terraform fmt -check
plan:
stage: plan
script:
- terraform plan -out=tfplan
artifacts:
paths:
- tfplan
apply:
stage: apply
script:
- terraform apply -auto-approve tfplan
dependencies:
- plan
only:
- main
Jenkins Pipeline
For Jenkins, create a Jenkinsfile:
pipeline {
agent {
docker {
image 'hashicorp/terraform:latest'
}
}
stages {
stage('Init') {
steps {
sh 'terraform init'
}
}
stage('Validate') {
steps {
sh 'terraform validate'
sh 'terraform fmt -check'
}
}
stage('Plan') {
steps {
sh 'terraform plan -out=tfplan'
}
}
stage('Apply') {
when {
branch 'main'
}
steps {
sh 'terraform apply -auto-approve tfplan'
}
}
}
}
Handling Terraform Workflow in CI/CD
Terraform Plan Stage
The terraform plan
command generates an execution plan showing what actions Terraform will take to change your infrastructure to match your configuration. In a CI/CD context:
- Run on every pull request
- Save the plan output as an artifact
- Post the plan as a comment on the PR for review
Here's how the output might look:
Terraform will perform the following actions:
# aws_instance.example will be created
+ resource "aws_instance" "example" {
+ ami = "ami-0c55b159cbfafe1f0"
+ instance_type = "t2.micro"
+ vpc_security_group_ids = (known after apply)
# ... more attributes
}
Plan: 1 to add, 0 to change, 0 to destroy.
Terraform Apply Stage
The terraform apply
command executes the actions proposed in a Terraform plan. In CI/CD:
- Only run on merges to the main branch
- Use the saved plan from the plan stage
- Run with
-auto-approve
to avoid manual intervention
Best Practices for Terraform in CI/CD
1. Use Workspaces for Environments
Terraform workspaces help manage multiple environments:
# Create workspaces for different environments
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
# Select a workspace
terraform workspace select dev
In your configuration, use the workspace name:
locals {
environment = terraform.workspace
}
resource "aws_instance" "example" {
# ...
tags = {
Environment = local.environment
}
}
2. Implement Drift Detection
Regularly run terraform plan
to detect manual changes:
# In GitHub Actions
name: "Detect Infrastructure Drift"
on:
schedule:
- cron: '0 8 * * *' # Run daily at 8 AM
jobs:
drift:
name: "Detect Drift"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Terraform Init
run: terraform init
- name: Detect Drift
run: |
terraform plan -detailed-exitcode
if [ $? -eq 2 ]; then
echo "Infrastructure drift detected!"
exit 1
fi
3. Secure Sensitive Variables
Never store secrets in your Terraform code. Instead, use environment variables or a secret manager:
# In GitHub Actions
jobs:
terraform:
# ...
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
4. Implement Automated Testing
Use tools like Terratest or Kitchen-Terraform to test your infrastructure:
// test/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVPC(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/networking",
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID, "VPC ID should not be empty")
}
Advanced Terraform CI/CD Workflows
Let's explore some advanced CI/CD techniques with Terraform:
Approval Workflow for Production Changes
Use a manual approval process for production changes:
Blue-Green Deployments with Terraform
Implement blue-green deployments for zero-downtime updates:
resource "aws_lb_target_group" "blue" {
name = "blue-target-group"
# ...
}
resource "aws_lb_target_group" "green" {
name = "green-target-group"
# ...
}
resource "aws_lb_listener_rule" "traffic_distribution" {
listener_arn = aws_lb_listener.front_end.arn
action {
type = "forward"
target_group_arn = var.enable_green ? aws_lb_target_group.green.arn : aws_lb_target_group.blue.arn
}
}
Real-World Example: Complete CI/CD Pipeline for AWS Infrastructure
Let's walk through a complete example of a CI/CD pipeline that deploys a simple web application infrastructure to AWS:
Project Structure
project/
├── .github/
│ └── workflows/
│ └── terraform.yml
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ └── terraform.tfvars
│ └── prod/
│ ├── main.tf
│ └── terraform.tfvars
├── modules/
│ ├── networking/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── database/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── web_app/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── README.md
Module Example: Web App
# modules/web_app/main.tf
resource "aws_launch_configuration" "web_app" {
name_prefix = "${var.app_name}-"
image_id = var.ami_id
instance_type = var.instance_type
user_data = <<-EOF
#!/bin/bash
echo "Hello from ${var.environment}" > index.html
nohup python -m SimpleHTTPServer 80 &
EOF
lifecycle {
create_before_destroy = true
}
}
resource "aws_autoscaling_group" "web_app" {
launch_configuration = aws_launch_configuration.web_app.id
min_size = var.min_size
max_size = var.max_size
vpc_zone_identifier = var.subnet_ids
tag {
key = "Name"
value = "${var.app_name}-${var.environment}"
propagate_at_launch = true
}
}
resource "aws_lb" "web_app" {
name = "${var.app_name}-lb"
internal = false
load_balancer_type = "application"
subnets = var.subnet_ids
}
resource "aws_lb_target_group" "web_app" {
name = "${var.app_name}-target-group"
port = 80
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
path = "/"
healthy_threshold = 2
unhealthy_threshold = 2
timeout = 3
interval = 30
}
}
resource "aws_lb_listener" "web_app" {
load_balancer_arn = aws_lb.web_app.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web_app.arn
}
}
Environment Configuration
# environments/dev/main.tf
provider "aws" {
region = var.region
}
module "networking" {
source = "../../modules/networking"
vpc_cidr = var.vpc_cidr
environment = "dev"
public_subnets = var.public_subnets
}
module "database" {
source = "../../modules/database"
db_name = var.db_name
engine = "mysql"
instance_class = "db.t3.small"
subnet_ids = module.networking.private_subnet_ids
environment = "dev"
}
module "web_app" {
source = "../../modules/web_app"
app_name = var.app_name
environment = "dev"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.public_subnet_ids
instance_type = "t3.small"
min_size = 2
max_size = 4
ami_id = var.ami_id
}
output "web_app_url" {
value = module.web_app.lb_dns_name
}
CI/CD Pipeline Configuration
# .github/workflows/terraform.yml
name: "Terraform CI/CD"
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
terraform:
name: "Terraform"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Terraform Init
run: |
cd environments/dev
terraform init
- name: Terraform Format
run: terraform fmt -check -recursive
- name: Terraform Validate
run: |
cd environments/dev
terraform validate
- name: Terraform Plan
id: plan
run: |
cd environments/dev
terraform plan -out=tfplan
continue-on-error: true
- name: Terraform Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
cd environments/dev
terraform apply -auto-approve tfplan
- name: Output Web App URL
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
cd environments/dev
echo "Web App URL: $(terraform output web_app_url)"
Common Challenges and Solutions
Challenge 1: State Locking in CI/CD
Problem: Multiple pipeline runs can conflict when accessing state.
Solution: Use a backend that supports state locking, like S3 with DynamoDB:
terraform {
backend "s3" {
bucket = "terraform-state-bucket"
key = "path/to/state"
region = "us-east-1"
dynamodb_table = "terraform-locks"
}
}
Challenge 2: Long-Running Operations
Problem: Some resources take a long time to create, causing pipeline timeouts.
Solution: Split your infrastructure into layers with dependencies:
Challenge 3: Managing Multiple Environments
Problem: Duplicating code for different environments leads to maintenance issues.
Solution: Use a combination of modules, workspaces, and variable files:
environments/
├── global.tfvars
├── dev.tfvars
├── staging.tfvars
└── prod.tfvars
Then in your pipeline:
terraform apply -var-file=environments/global.tfvars -var-file=environments/dev.tfvars
Summary
In this guide, we've explored how to integrate Terraform with CI/CD pipelines to automate infrastructure deployments. We've covered:
- Basic Terraform concepts and workflow
- Setting up Terraform for CI/CD
- Integrating Terraform with popular CI/CD platforms
- Best practices for Terraform in CI/CD
- Advanced Terraform CI/CD workflows
- A real-world example of a complete CI/CD pipeline
By combining the power of Infrastructure as Code with CI/CD automation, you can achieve:
- Faster, more reliable infrastructure deployments
- Consistency across environments
- Better collaboration between development and operations teams
- Increased confidence in infrastructure changes
Additional Resources
Here are some resources to further your learning:
Exercises
- Set up a basic Terraform project with remote state storage
- Create a GitHub Actions workflow to apply Terraform changes
- Implement a multi-environment setup using Terraform workspaces
- Add automated testing to your Terraform modules
- Create a blue-green deployment pipeline for a web application
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)