Jenkins Lesson 40 – Jenkins and Infrastructure as Code | Dataplexa
Section IV · Lesson 40

Jenkins and Infrastructure as Code

Application deployments and infrastructure changes are two sides of the same coin. A Jenkins pipeline that deploys your app but leaves you clicking through a cloud console to provision the underlying infrastructure isn't fully automated. This lesson closes that gap.

This lesson covers

IaC tools overview → Terraform in Jenkins pipelines → Plan/apply approval gates → State management → Ansible for configuration management → Drift detection pipelines → The IaC pipeline safety patterns

Infrastructure as Code (IaC) tools like Terraform, Ansible, and Pulumi let you define cloud resources — VPCs, databases, Kubernetes clusters, load balancers — as code that can be versioned, reviewed, and applied automatically. Jenkins pipelines run this code the same way they run application builds: triggered by a Git push, reviewed in a pull request, with approval gates before production changes.

The Analogy

Manual infrastructure changes through a cloud console are like a chef changing the restaurant's kitchen layout on a whim — no record of what changed, no way to reproduce the original, no review before expensive equipment gets moved. IaC in Jenkins is like an architect's blueprint that goes through approval before any construction begins. Every change is documented, every change is reviewed, and every change can be rolled back from the version history.

IaC Tools and Where Each Fits

Terraform

Cloud provisioning

Provisions and manages cloud infrastructure — EC2 instances, RDS databases, VPCs, EKS clusters. Declarative HCL syntax. Maintains a state file that tracks what exists. The most widely used IaC tool. Use it in Jenkins to provision infrastructure before deploying applications into it.

Ansible

Configuration management

Configures servers — installs packages, sets config files, manages services, creates users. Agentless — connects via SSH. Use it in Jenkins to configure newly provisioned infrastructure or to enforce consistent configuration across a fleet of servers.

Pulumi

Code-first IaC

Like Terraform but infrastructure is defined in real programming languages — TypeScript, Python, Go. Better IDE support, proper testing frameworks, and full language features for complex logic. A growing alternative to Terraform for teams preferring general-purpose languages over HCL.

Terraform Pipeline — Plan, Approve, Apply

The scenario:

You're a DevOps engineer at a company that manages AWS infrastructure with Terraform. Infrastructure changes go through Git — a developer raises a PR, the pipeline runs terraform plan, the output is reviewed, and an engineer approves before terraform apply runs. No one touches the cloud console for infrastructure changes.

New terms in this code:

  • terraform init — initialises the Terraform working directory, downloading provider plugins and configuring the backend. Must run before any other Terraform command.
  • terraform plan — compares desired state (your .tf files) against current state (the state file) and outputs a human-readable diff of what will be created, changed, or destroyed. The -out=tfplan flag saves the plan to a file for use in apply.
  • terraform apply — applies the saved plan, making the actual cloud API calls to create/change/destroy resources. Running apply from a saved plan file ensures exactly what was reviewed gets applied — not a new plan generated after review.
  • terraform state — the file (or remote backend) that tracks what infrastructure Terraform currently manages. Store it in S3 with DynamoDB locking for team use — never in the repository or in Jenkins workspace.
  • -detailed-exitcode — a Terraform plan flag that returns exit code 2 when there are changes to apply (vs 0 for no changes, 1 for error). Used in pipelines to skip the apply gate if nothing changed.
// Jenkinsfile for a Terraform infrastructure pipeline
// Pattern: PR → Plan (auto) → Review → Approve → Apply (main only)
pipeline {
    agent {
        docker {
            // Use the official Terraform Docker image — no installation needed on agents
            image 'hashicorp/terraform:1.7'
            args  '-v /tmp/terraform-plugins:/root/.terraform.d/plugins'
        }
    }

    environment {
        // AWS credentials from Jenkins credential store — never hardcoded
        AWS_ACCESS_KEY_ID     = credentials('aws-terraform-access-key')
        AWS_SECRET_ACCESS_KEY = credentials('aws-terraform-secret-key')
        // Or better: use an IAM role on the agent and skip these entirely

        // Terraform state backend — S3 bucket and DynamoDB lock table
        TF_STATE_BUCKET  = 'acmecorp-terraform-state'
        TF_LOCK_TABLE    = 'acmecorp-terraform-locks'
        TF_VAR_env       = 'production'
    }

    stages {

        stage('Checkout') {
            steps { checkout scm }
        }

        stage('Terraform Init') {
            steps {
                sh '''
                    terraform init \
                      -backend-config="bucket=${TF_STATE_BUCKET}" \
                      -backend-config="dynamodb_table=${TF_LOCK_TABLE}" \
                      -backend-config="region=eu-west-1" \
                      -input=false
                '''
            }
        }

        stage('Terraform Validate') {
            steps {
                // Validate syntax before running a plan — catches .tf file errors fast
                sh 'terraform validate'
            }
        }

        stage('Terraform Plan') {
            steps {
                script {
                    // -detailed-exitcode: 0=no changes, 1=error, 2=changes pending
                    def exitCode = sh(
                        script: 'terraform plan -out=tfplan -detailed-exitcode -input=false',
                        returnStatus: true
                    )

                    if (exitCode == 0) {
                        echo "No infrastructure changes — skipping apply"
                        currentBuild.description = "No changes"
                        env.SKIP_APPLY = 'true'
                    } else if (exitCode == 2) {
                        echo "Infrastructure changes detected — plan saved"
                        env.SKIP_APPLY = 'false'
                        // Show the plan in the Jenkins console for reviewers
                        sh 'terraform show tfplan'
                    } else {
                        error('terraform plan failed')
                    }
                }
            }
            // Archive the plan file so reviewers can inspect it
            post {
                always {
                    archiveArtifacts artifacts: 'tfplan', allowEmptyArchive: true
                }
            }
        }

        stage('Approval Gate') {
            // Only gate on main branch — PR builds plan without applying
            when {
                allOf {
                    branch 'main'
                    expression { return env.SKIP_APPLY == 'false' }
                }
            }
            steps {
                // Human must approve before infrastructure is changed
                input(
                    message: 'Review the Terraform plan above. Apply to production?',
                    ok: 'Apply Infrastructure Changes',
                    submitter: 'platform-engineers,infra-leads'
                )
            }
        }

        stage('Terraform Apply') {
            when {
                allOf {
                    branch 'main'
                    expression { return env.SKIP_APPLY == 'false' }
                }
            }
            steps {
                sh '''
                    # Apply the SAVED plan — not a fresh plan
                    # This ensures exactly what was reviewed gets applied
                    terraform apply -input=false tfplan
                '''
            }
        }

    }

    post {
        success {
            slackSend(channel: '#infra-changes', color: 'good',
                message: "✅ Terraform applied to production — <${env.BUILD_URL}|Build #${BUILD_NUMBER}>")
        }
        failure {
            slackSend(channel: '#infra-changes', color: 'danger',
                message: "❌ Terraform pipeline failed — <${env.BUILD_URL}|Build #${BUILD_NUMBER}>")
        }
        always { cleanWs() }
    }
}

Where to practice: Install Terraform locally and create a minimal AWS config (provider "aws" {} and one aws_s3_bucket). Run terraform plan and terraform apply manually first to understand the output. Then wrap it in a Jenkinsfile. For the S3 backend, the Terraform docs walk through the setup at developer.hashicorp.com — S3 backend.

[Pipeline] { (Terraform Init) }
Initializing the backend...
Successfully configured the backend "s3"!
Terraform has been successfully initialized!

[Pipeline] { (Terraform Validate) }
Success! The configuration is valid.

[Pipeline] { (Terraform Plan) }
Terraform will perform the following actions:

  # aws_security_group.app will be modified
  ~ resource "aws_security_group" "app" {
      ~ ingress = [
          + {
              + cidr_blocks = ["10.0.0.0/8"]
              + from_port   = 443
              + to_port     = 443
              + protocol    = "tcp"
            },
        ]
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Infrastructure changes detected — plan saved
Archiving tfplan...

[Pipeline] { (Approval Gate) }
Waiting for approval: Review the Terraform plan above. Apply to production?
Approved by: alice (platform-engineer)

[Pipeline] { (Terraform Apply) }
aws_security_group.app: Modifying...
aws_security_group.app: Modifications complete after 3s

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

Slack: ✅ Terraform applied to production
Finished: SUCCESS

What just happened?

  • Plan ran automatically, apply required approval — any push to any branch runs the plan so reviewers can see what would change during code review. Only pushes to main trigger the approval gate and apply. Feature branch Terraform changes are reviewed exactly like feature branch application changes.
  • Apply used the saved plan fileterraform apply tfplan applies exactly the plan that was reviewed. If someone pushes another commit between plan and approval, that commit's changes are NOT applied — the saved plan is immutable. This is the critical safety property. Never run terraform apply without a saved plan in a CI pipeline.
  • exit code 2 = changes pending — the -detailed-exitcode flag means the pipeline can skip the entire approval-and-apply flow when there's nothing to change. A PR that only touches documentation won't trigger an infra approval workflow.
  • AWS credentials from the credential store — the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY come from Jenkins credentials, not from environment variables set in the Jenkinsfile or hardcoded anywhere. For EC2-based agents, an IAM role on the agent instance is even better — no credentials at all.

Ansible in Jenkins — Configuration Management Pipelines

While Terraform provisions infrastructure, Ansible configures it. A common pattern is a two-stage pipeline: Terraform creates the servers, Ansible configures them. Jenkins orchestrates both.

// Ansible stage — runs after Terraform has provisioned the servers
// Uses the Ansible plugin (ansible) from the Jenkins Plugin Manager
stage('Configure Servers') {
    steps {
        // ansiblePlaybook is a step from the Ansible plugin
        ansiblePlaybook(
            playbook: 'ansible/site.yml',
            inventory: 'ansible/inventory/production',
            credentialsId: 'ansible-ssh-key',   // SSH key to connect to servers
            colorized: true,                     // coloured output in console
            disableHostKeyChecking: true,
            extraVars: [
                app_version: "${BUILD_NUMBER}",
                deploy_env:  "production"
            ]
        )
    }
}

// Alternatively — run ansible-playbook directly as a shell command
// Gives more control but requires Ansible installed on the agent
stage('Configure Servers — Shell') {
    steps {
        withCredentials([sshUserPrivateKey(
            credentialsId: 'ansible-ssh-key',
            keyFileVariable: 'SSH_KEY'
        )]) {
            sh """
                ansible-playbook ansible/site.yml \
                  -i ansible/inventory/production \
                  --private-key=${SSH_KEY} \
                  --extra-vars "app_version=${BUILD_NUMBER} deploy_env=production" \
                  --diff          # shows what changed on each server
            """
        }
    }
}
PLAY [Configure application servers] ****

TASK [Gathering Facts] **
ok: [10.0.1.45]
ok: [10.0.1.46]

TASK [Install Java 21] **
ok: [10.0.1.45]
changed: [10.0.1.46]      ← this server needed updating

TASK [Deploy application config] **
changed: [10.0.1.45]
changed: [10.0.1.46]

TASK [Start application service] **
ok: [10.0.1.45]
ok: [10.0.1.46]

PLAY RECAP
10.0.1.45 : ok=4  changed=1  unreachable=0  failed=0
10.0.1.46 : ok=4  changed=2  unreachable=0  failed=0

What just happened?

  • Idempotent runsok means the task ran and nothing needed to change. changed means Ansible made a change. Running the same playbook twice produces only ok results the second time — Ansible doesn't re-apply things that are already correct. This is the idempotency property that makes Ansible safe to run in CI.
  • Drift visible in output — server 10.0.1.46 needed Java updated but 10.0.1.45 didn't. This difference is visible in the Jenkins console output. If you run Ansible daily as a drift-detection job, any server that drifted from desired state shows as changed and the ticket goes to whoever owns that server.
  • --diff shows exact changes — when a config file is modified, Ansible prints a unified diff of before and after. This is invaluable in a CI pipeline — reviewers can see the exact configuration change that was applied, just like a code diff.

IaC Pipeline Safety Patterns

Always use a saved plan

Run terraform plan -out=tfplan then terraform apply tfplan. Never terraform apply without a saved plan — that generates a fresh plan that wasn't reviewed.

Gate destructive changes separately

Check if the plan contains any destroy operations. Require a second approver or a separate pipeline for plans that delete production resources.

Remote state with locking

Store Terraform state in S3 with DynamoDB locking. Two concurrent pipelines cannot apply at the same time — the second one waits for the lock to release. Prevents state corruption.

Never store state in the repository

The state file contains resource IDs and sometimes sensitive values. Committing it to Git exposes infrastructure details and causes conflicts when multiple team members apply changes.

Never auto-apply on every merge

Application deployments can be automated. Infrastructure changes should require human review of the plan before apply — a missed destroy operation can take down production resources instantly.

Teacher's Note

Treat infrastructure pipelines with more caution than application pipelines. An application deployment that goes wrong can be rolled back in minutes. An infrastructure change that deletes a database cannot.

Practice Questions

1. Which Terraform command saves the execution plan to a file so that exactly what was reviewed gets applied — and nothing else?



2. Which Terraform flag makes terraform plan return exit code 2 when there are changes to apply, allowing the pipeline to skip the approval gate when nothing changed?



3. Where should Terraform state be stored in a team environment to enable concurrent pipeline safety and prevent two simultaneous applies from corrupting the state?



Quiz

1. What is the functional difference between Terraform and Ansible in a CI/CD pipeline?


2. Why does the Terraform pipeline run terraform apply tfplan rather than terraform apply after approval?


3. In Ansible output, what does a task result of changed indicate compared to ok?


Up Next · Lesson 41

Jenkins for Microservices

One pipeline per service, fan-out orchestration, and the shared library patterns that keep 30 independent Jenkinsfiles from becoming a maintenance nightmare.