How to Use Jenkins with Terraform for Infrastructure as Code

How to Use Jenkins with Terraform for Infrastructure as Code

I still remember the state of our infrastructure before Terraform — a pile of tribal knowledge, manually created resources, and no real record of who changed what. Once Terraform entered the picture, the natural next step was making sure terraform plan and terraform apply ran through the same reviewed, automated pipeline as our application code, instead of from someone’s laptop. This guide covers building a solid Jenkins pipeline around Terraform, including state management, plan review gates, and safe apply automation.

Why Run Terraform Through Jenkins

Running terraform apply manually from a laptop means no consistent audit trail, no guaranteed use of the same Terraform version across the team, and no enforced review step before infrastructure changes hit production. Jenkins fixes all three: it runs Terraform with a pinned version and locked provider versions, requires a plan review before any apply, and logs every run with full output.

Jenkins Architecture for Terraform Pipelines

Prerequisites

Step 1: Install Terraform on the Jenkins Agent

wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install -y terraform
terraform -version

Pin the version explicitly rather than always installing “latest” — an unexpected Terraform version bump mid-project can silently change plan behavior.

Step 2: Set Up Remote State with Locking

Example S3 + DynamoDB backend (AWS):

terraform {
  backend "s3" {
    bucket         = "myorg-terraform-state"
    key            = "myapp/production/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

The DynamoDB table provides state locking, so two concurrent Jenkins runs can’t corrupt state by applying simultaneously — Terraform will simply make the second run wait or fail with a clear lock error.

Step 3: Install Jenkins Plugins

Step 4: Write the Jenkinsfile with Plan/Approve/Apply Stages

pipeline {
    agent any

    environment {
        AWS_DEFAULT_REGION = 'us-east-1'
        TF_IN_AUTOMATION = 'true'
    }

    stages {
        stage('Checkout') {
            steps {
                git branch: 'main', url: 'https://github.com/yourorg/infra.git'
            }
        }

        stage('Terraform Init') {
            steps {
                withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: 'aws-terraform-creds']]) {
                    sh 'terraform init -input=false'
                }
            }
        }

        stage('Terraform Validate') {
            steps {
                sh 'terraform validate'
            }
        }

        stage('Terraform Plan') {
            steps {
                withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: 'aws-terraform-creds']]) {
                    sh 'terraform plan -input=false -out=tfplan'
                    sh 'terraform show -no-color tfplan > tfplan.txt'
                }
            }
        }

        stage('Archive Plan') {
            steps {
                archiveArtifacts artifacts: 'tfplan, tfplan.txt', fingerprint: true
            }
        }

        stage('Approve Apply') {
            steps {
                script {
                    def planOutput = readFile('tfplan.txt')
                    echo planOutput
                }
                input message: 'Review the Terraform plan above. Apply these changes?'
            }
        }

        stage('Terraform Apply') {
            steps {
                withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: 'aws-terraform-creds']]) {
                    sh 'terraform apply -input=false tfplan'
                }
            }
        }
    }

    post {
        always {
            sh 'rm -f tfplan'
        }
        failure {
            echo 'Terraform run failed — check the plan/apply logs above.'
        }
    }
}

Applying the exact saved tfplan file (rather than re-running terraform apply fresh) guarantees the changes actually applied match exactly what was reviewed and approved — no drift between plan and apply caused by a state change in between.

Step 5: Automated Validation and Linting

Before the plan stage, add static checks to catch issues early:

stage('Format Check') {
    steps {
        sh 'terraform fmt -check -recursive'
    }
}

stage('TFLint') {
    steps {
        sh 'tflint --init && tflint'
    }
}

stage('Security Scan with tfsec') {
    steps {
        sh 'tfsec . --format junit > tfsec-report.xml || true'
        junit 'tfsec-report.xml'
    }
}

tfsec (or checkov) catches security misconfigurations — open security groups, unencrypted storage, overly permissive IAM — before they ever reach a plan, let alone an apply.

Step 6: Multi-Environment Pipelines with Workspaces or Directories

Two common patterns for managing dev/staging/production:

Terraform Workspaces:

stage('Select Workspace') {
    steps {
        sh "terraform workspace select ${params.ENVIRONMENT} || terraform workspace new ${params.ENVIRONMENT}"
    }
}

Directory-per-environment (often preferred for production, since it fully isolates state and variable files):

stage('Plan') {
    steps {
        dir("environments/${params.ENVIRONMENT}") {
            sh 'terraform init -input=false'
            sh 'terraform plan -input=false -out=tfplan'
        }
    }
}

Directory-per-environment avoids the “one workspace typo applies to the wrong environment” risk that workspaces can introduce, at the cost of some code duplication that a shared module structure can offset.

Step 7: Drift Detection

Schedule a separate, apply-free pipeline to run terraform plan periodically and alert if it detects drift — infrastructure changed outside of Terraform (someone clicked around in the console):

pipeline {
    agent any
    triggers {
        cron('H 6 * * *')
    }
    stages {
        stage('Drift Check') {
            steps {
                sh 'terraform plan -input=false -detailed-exitcode -out=drift.tfplan'
            }
        }
    }
    post {
        failure {
            // exit code 2 means changes detected (drift)
            echo 'Drift detected! Notify the team.'
        }
    }
}

-detailed-exitcode returns 0 for no changes, 1 for an error, and 2 for detected changes — perfect for a scheduled job whose entire purpose is flagging drift rather than applying anything.

Real-World Workflow

  1. An engineer opens a PR modifying a .tf file.
  2. Jenkins runs fmt -check, tflint, tfsec, and terraform plan, posting the plan output as a PR comment for review.
  3. On merge to main, Jenkins re-runs plan and pauses at the manual approval gate, showing the exact plan output.
  4. A team lead reviews and approves; Jenkins applies the exact saved plan file.
  5. A nightly scheduled job runs drift detection across all environments and alerts if manual changes crept in outside the pipeline.

Security Best Practices

Troubleshooting

Using terraform-docs and Cost Estimation

Two more tools worth adding to a Terraform pipeline for larger teams:

terraform-docs auto-generates human-readable documentation for each module’s inputs and outputs, keeping documentation in sync with code without manual upkeep:

stage('Generate Module Docs') {
    steps {
        sh 'terraform-docs markdown table --output-file README.md ./modules/vpc'
    }
}

Infracost estimates the dollar impact of a plan before it’s applied, which is especially valuable for catching an accidental instance-type change or a forgotten count multiplier before it shows up on next month’s cloud bill:

stage('Cost Estimate') {
    steps {
        sh 'infracost breakdown --path=tfplan --format=table'
    }
}

Posting the Infracost output alongside the plan in the PR comment gives reviewers both the infrastructure diff and its cost implications in one place, which tends to catch expensive mistakes far earlier than a monthly billing review would.

FAQs

Should every environment go through a manual approval gate, or just production? Many teams auto-apply to dev/sandbox environments for fast iteration, and require manual approval only for staging and production, where the blast radius of a mistake is higher.

Can Jenkins post the Terraform plan output directly on a GitHub pull request? Yes, using the GitHub API (or the pull request comment step from a GitHub plugin) to post the terraform show output as a PR comment, giving reviewers full visibility before merge.

How do I handle Terraform state for multiple teams working on the same infrastructure repo? Split state by service or environment (separate backend key values) so teams aren’t fighting over the same lock, and consider Terraform modules to share common patterns without sharing state.

What Terraform version should the pipeline use? Pin an exact version in the pipeline (and ideally in a .terraform-version file read by a version manager like tfenv) so local development and CI never drift apart.

Summary

The core discipline of running Terraform through Jenkins is separating plan from apply, always applying the exact reviewed plan file, and never letting infrastructure changes bypass the pipeline via someone’s local terraform apply. Combined with remote state locking, static security scanning, and scheduled drift detection, this turns infrastructure changes into the same reviewed, auditable process your application deployments already go through.

References

Exit mobile version