Ansible Lesson 38 – Ansible with Jenkins | Dataplexa
Section III · Lesson 38

Ansible with Jenkins

In this lesson

Ansible Jenkins plugin Declarative Jenkinsfile Credentials management Parameterised builds Shared libraries

Jenkins remains the most widely deployed CI/CD platform in enterprise environments — and many teams that already run Jenkins want to integrate Ansible into existing pipelines rather than migrating to GitHub Actions or GitLab CI. Ansible and Jenkins complement each other well: Jenkins orchestrates the pipeline stages, manages build triggers, and provides the web UI and audit trail; Ansible handles the actual configuration and deployment work that Jenkins stages invoke. This lesson covers the Ansible Jenkins plugin, writing Declarative Jenkinsfiles that call Ansible, injecting vault passwords and SSH keys from Jenkins credentials, parameterised builds for version and environment selection, and packaging reusable Ansible pipeline logic in a Jenkins shared library.

The Ansible Jenkins Plugin

The Ansible plugin for Jenkins provides two dedicated pipeline steps — ansiblePlaybook and ansibleAdhoc — that handle Ansible installation path resolution, inventory specification, vault password injection, and verbose output formatting. Using the plugin steps is cleaner than calling ansible-playbook directly in a shell step, but both approaches work.

Plugin installation

In Jenkins: Manage Jenkins → Plugins → Available → search "Ansible" → install Ansible plugin. After installation, configure the Ansible installation path under Manage Jenkins → Tools → Ansible installations. Point it to the ansible binary on the Jenkins agent.

// Jenkinsfile — using the Ansible plugin steps
pipeline {
    agent any

    stages {
        stage('Deploy') {
            steps {
                // ansiblePlaybook plugin step
                ansiblePlaybook(
                    playbook: 'site.yml',
                    inventory: 'inventory/production/',
                    credentialsId: 'ansible-ssh-key',        // Jenkins SSH credential
                    vaultCredentialsId: 'ansible-vault-pass', // Jenkins secret text
                    colorized: true,
                    extras: '-e app_version=${APP_VERSION}'
                )
            }
        }
    }
}

Jenkins Credentials for Ansible

Jenkins has a built-in credentials store that securely holds SSH keys, secret text, and username/password pairs. Ansible needs two credential types — an SSH private key for connecting to managed nodes and a secret text for the vault password. Both are stored in Jenkins and injected at runtime without ever appearing in the Jenkinsfile or build logs.

SSH Private Key credential
Kind: SSH Username with private key
ID: ansible-ssh-key
Username: ansible
Private Key: paste the PEM key directly

Jenkins writes it to a temp file and sets --private-key automatically when referenced via credentialsId.
Vault password credential
Kind: Secret text
ID: ansible-vault-pass
Secret: the vault password

Jenkins writes it to a temp file and passes it as --vault-password-file. The password never appears in logs or the Jenkinsfile.
// Manually injecting credentials when not using the plugin step
pipeline {
    agent any
    stages {
        stage('Deploy') {
            steps {
                withCredentials([
                    sshUserPrivateKey(
                        credentialsId: 'ansible-ssh-key',
                        keyFileVariable: 'SSH_KEY_FILE',
                        usernameVariable: 'SSH_USER'
                    ),
                    string(
                        credentialsId: 'ansible-vault-pass',
                        variable: 'VAULT_PASS'
                    )
                ]) {
                    sh '''
                        echo "$VAULT_PASS" > .vault_pass
                        chmod 600 .vault_pass
                        ansible-playbook site.yml \\
                          -i inventory/production/ \\
                          --private-key "$SSH_KEY_FILE" \\
                          -u "$SSH_USER" \\
                          --vault-password-file .vault_pass
                        rm -f .vault_pass
                    '''
                }
            }
        }
    }
}

Complete Declarative Jenkinsfile

The complete pipeline below implements all four stages from Lesson 37 — lint, test, staging, production — as a declarative Jenkinsfile. The production stage uses Jenkins' built-in input step as the manual approval gate.

// Jenkinsfile
pipeline {
    agent {
        docker {
            image 'python:3.12-slim'
            args '-v /var/run/docker.sock:/var/run/docker.sock'
        }
    }

    parameters {
        string(name: 'APP_VERSION',   defaultValue: 'latest', description: 'Application version to deploy')
        choice(name: 'ENVIRONMENT',   choices: ['staging', 'production'],  description: 'Target environment')
        booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: 'Skip Molecule tests (emergency only)')
    }

    environment {
        ANSIBLE_FORCE_COLOR  = '1'
        ANSIBLE_HOST_KEY_CHECKING = 'False'
    }

    stages {

        // ── Stage 1: Install dependencies ──────────────────────────
        stage('Setup') {
            steps {
                sh 'pip install ansible ansible-lint yamllint molecule molecule-plugins[docker]'
                sh 'ansible-galaxy install -r requirements.yml'
            }
        }

        // ── Stage 2: Lint and syntax check ─────────────────────────
        stage('Validate') {
            steps {
                sh 'yamllint .'
                sh 'ansible-lint'
                sh 'ansible-playbook site.yml --syntax-check -i inventory/staging/'
            }
            post {
                failure {
                    echo 'Lint failed — fix errors before deploying'
                }
            }
        }

        // ── Stage 3: Molecule role tests ───────────────────────────
        stage('Test') {
            when {
                expression { return !params.SKIP_TESTS }
            }
            parallel {
                stage('Test: common') {
                    steps { sh 'cd roles/common && molecule test' }
                }
                stage('Test: nginx') {
                    steps { sh 'cd roles/nginx && molecule test' }
                }
                stage('Test: postgresql') {
                    steps { sh 'cd roles/postgresql && molecule test' }
                }
            }
        }

        // ── Stage 4: Deploy to staging ─────────────────────────────
        stage('Deploy Staging') {
            when {
                anyOf {
                    branch 'main'
                    expression { params.ENVIRONMENT == 'staging' }
                }
            }
            steps {
                withCredentials([
                    sshUserPrivateKey(credentialsId: 'ansible-ssh-key', keyFileVariable: 'SSH_KEY'),
                    string(credentialsId: 'ansible-vault-pass', variable: 'VAULT_PASS')
                ]) {
                    sh '''
                        echo "$VAULT_PASS" > .vault_pass
                        ansible-playbook site.yml \\
                          -i inventory/staging/ \\
                          --private-key "$SSH_KEY" \\
                          --vault-password-file .vault_pass \\
                          -e app_version=${APP_VERSION} \\
                          --check --diff
                        ansible-playbook site.yml \\
                          -i inventory/staging/ \\
                          --private-key "$SSH_KEY" \\
                          --vault-password-file .vault_pass \\
                          -e app_version=${APP_VERSION}
                        ansible-playbook smoke_tests.yml \\
                          -i inventory/staging/ \\
                          --private-key "$SSH_KEY" \\
                          --vault-password-file .vault_pass \\
                          -e app_version=${APP_VERSION}
                        rm -f .vault_pass
                    '''
                }
            }
        }

        // ── Stage 5: Manual approval gate ──────────────────────────
        stage('Approve Production') {
            when {
                expression { params.ENVIRONMENT == 'production' }
            }
            steps {
                timeout(time: 24, unit: 'HOURS') {
                    input(
                        message: "Deploy ${params.APP_VERSION} to PRODUCTION?",
                        ok: 'Deploy',
                        submitterParameter: 'APPROVED_BY'
                    )
                }
            }
        }

        // ── Stage 6: Deploy to production ──────────────────────────
        stage('Deploy Production') {
            when {
                expression { params.ENVIRONMENT == 'production' }
            }
            steps {
                withCredentials([
                    sshUserPrivateKey(credentialsId: 'ansible-ssh-key-prod', keyFileVariable: 'SSH_KEY'),
                    string(credentialsId: 'ansible-vault-pass-prod', variable: 'VAULT_PASS')
                ]) {
                    sh '''
                        echo "$VAULT_PASS" > .vault_pass
                        ansible-playbook site.yml \\
                          -i inventory/production/ \\
                          --private-key "$SSH_KEY" \\
                          --vault-password-file .vault_pass \\
                          -e app_version=${APP_VERSION}
                        ansible-playbook smoke_tests.yml \\
                          -i inventory/production/ \\
                          --private-key "$SSH_KEY" \\
                          --vault-password-file .vault_pass \\
                          -e app_version=${APP_VERSION}
                        rm -f .vault_pass
                    '''
                }
            }
        }
    }

    post {
        always {
            sh 'rm -f .vault_pass'     // safety net — ensure cleanup even on crash
            cleanWs()                  // clean Jenkins workspace after every run
        }
        success {
            echo "Pipeline completed: ${params.APP_VERSION} → ${params.ENVIRONMENT}"
        }
        failure {
            mail(
                to: 'ops@example.com',
                subject: "FAILED: Ansible pipeline ${env.JOB_NAME} #${env.BUILD_NUMBER}",
                body: "Build failed: ${env.BUILD_URL}"
            )
        }
    }
}
Started by user ops-engineer (approved by: senior-engineer)
[Pipeline] stage: Setup
+ pip install ansible ansible-lint yamllint molecule ...
[Pipeline] stage: Validate
+ yamllint .
+ ansible-lint
  Passed: 0 violation(s) found.
+ ansible-playbook site.yml --syntax-check
  playbook: site.yml — syntax OK

[Pipeline] stage: Test (parallel)
  [Test: common]      PASSED  2m14s
  [Test: nginx]       PASSED  1m58s
  [Test: postgresql]  PASSED  2m41s

[Pipeline] stage: Deploy Staging
  TASK [Deploy Nginx configuration]
  --- before   +++ after
  - worker_connections 1024;
  + worker_connections 2048;
  changed: [staging-web01]
  PLAY RECAP: staging-web01 ok=18 changed=3 failed=0

[Pipeline] stage: Approve Production
  Waiting for approval... (approved by: senior-engineer)

[Pipeline] stage: Deploy Production
  PLAY RECAP: web01 ok=18 changed=3 failed=0
              web02 ok=18 changed=3 failed=0
              web03 ok=18 changed=3 failed=0

Finished: SUCCESS

What just happened?

The pipeline ran all six stages end to end. The three Molecule tests ran in parallel, completing in under 3 minutes. The staging deployment showed the --check --diff output before applying — confirming only worker_connections would change. The production stage paused for 24 hours waiting for manual approval; a senior engineer reviewed and approved, and the deployment ran successfully across all three production hosts.

Parameterised Builds

The Jenkinsfile above already uses parameters — APP_VERSION, ENVIRONMENT, and SKIP_TESTS. Parameterised builds make the pipeline reusable across different versions and environments without code changes. Engineers trigger a build from the Jenkins UI and fill in the parameters, or CI/CD triggers set them automatically.

// Triggering a parameterised Jenkins build via the REST API (from another pipeline)
curl -X POST \
  "https://jenkins.example.com/job/ansible-deploy/buildWithParameters" \
  --user "ci-user:${JENKINS_API_TOKEN}" \
  --data "APP_VERSION=2.4.1" \
  --data "ENVIRONMENT=production" \
  --data "SKIP_TESTS=false"

// Triggering from another Jenkinsfile (upstream/downstream pipeline chaining)
stage('Trigger Ansible Deploy') {
    steps {
        build(
            job: 'ansible-deploy',
            parameters: [
                string(name: 'APP_VERSION', value: env.BUILD_VERSION),
                string(name: 'ENVIRONMENT', value: 'staging')
            ],
            wait: true,          // wait for the triggered pipeline to complete
            propagate: true      // fail this pipeline if the triggered one fails
        )
    }
}

Jenkins Shared Library

When multiple projects need the same Ansible pipeline logic, a Jenkins shared library packages that logic into reusable Groovy functions. Projects import the library and call a single function — the pipeline stages, credential handling, and cleanup are all encapsulated. Changes to the shared library propagate to all pipelines that use it without requiring changes in each repository.

// vars/ansibleDeploy.groovy — shared library function
// Stored in a separate Git repo registered in Jenkins as a shared library

def call(Map config) {
    // config keys: playbook, inventory, version, vaultCredId, sshCredId

    def playbook  = config.playbook  ?: 'site.yml'
    def inventory = config.inventory ?: "inventory/${config.environment}/"
    def version   = config.version   ?: error('version is required')

    withCredentials([
        sshUserPrivateKey(credentialsId: config.sshCredId,   keyFileVariable: 'SSH_KEY'),
        string(credentialsId:            config.vaultCredId,  variable: 'VAULT_PASS')
    ]) {
        sh """
            echo "\$VAULT_PASS" > .vault_pass
            pip install ansible -q
            ansible-galaxy install -r requirements.yml -q
            ansible-playbook ${playbook} \\
              -i ${inventory} \\
              --private-key "\$SSH_KEY" \\
              --vault-password-file .vault_pass \\
              -e app_version=${version}
            rm -f .vault_pass
        """
    }
}


// ─── How any project uses the shared library ─────────────────────────────
// Jenkinsfile in the application repo:

@Library('ansible-shared-library') _

pipeline {
    agent any
    stages {
        stage('Deploy Staging') {
            steps {
                ansibleDeploy(
                    environment: 'staging',
                    version:     params.APP_VERSION,
                    sshCredId:   'ansible-ssh-key',
                    vaultCredId: 'ansible-vault-pass'
                )
            }
        }
        stage('Deploy Production') {
            steps {
                input 'Deploy to production?'
                ansibleDeploy(
                    environment: 'production',
                    version:     params.APP_VERSION,
                    sshCredId:   'ansible-ssh-key-prod',
                    vaultCredId: 'ansible-vault-pass-prod'
                )
            }
        }
    }
}

The Shared Library Analogy

A Jenkins shared library is to Jenkinsfiles what an Ansible role is to playbooks. The deployment logic is written once, tested, and versioned in its own repository. Every team that needs Ansible deployment calls the shared function — they get the correct credential handling, cleanup, and error reporting without writing it themselves. When the platform team updates the shared library (new Ansible version, improved error handling), every pipeline using it benefits automatically.

Jenkins vs GitHub Actions — Choosing the Right Tool

Choose Jenkins when…
Already running Jenkins for other pipelines
Agents need network access to internal infrastructure not reachable from GitHub
Enterprise compliance requires on-premise execution
Complex shared library logic already exists in Groovy
Choose GitHub Actions when…
Code is already on GitHub and you want zero infrastructure to manage
Starting a new project — simpler setup and YAML-native configuration
Managed runners are acceptable — GitHub provides Ubuntu, macOS, Windows
Marketplace actions cover most integration needs

The Jenkins Agent Must Have Ansible Installed and Network Access to Managed Nodes

The most common Jenkins + Ansible integration failure is a pipeline that calls ansible-playbook on a Jenkins agent that either does not have Ansible installed or cannot reach the managed nodes over SSH. Always configure a dedicated Jenkins agent (or Docker agent image) with Ansible pre-installed and network connectivity to your infrastructure. Using the Docker agent (image: python:3.12-slim) with pip install ansible in the Setup stage is the most reproducible approach — the agent environment is identical on every run.

Key Takeaways

Store all credentials in the Jenkins credentials store — SSH keys as "SSH Username with private key", vault passwords as "Secret text". Inject them with withCredentials; they never appear in Jenkinsfiles or logs.
Use the input step with a timeout for production gates — the pipeline pauses and waits for a named approver. Set a reasonable timeout (24 hours) so stale approvals do not block the pipeline indefinitely.
Run Molecule tests in parallel Jenkins stages — use the parallel block to test multiple roles simultaneously, matching the matrix pattern from GitHub Actions.
Package reusable pipeline logic in a shared library — teams call ansibleDeploy(...) instead of duplicating credential handling and cleanup logic across every project's Jenkinsfile.
Always clean up in post { always {} } — delete vault password files and SSH keys in the post block so they are removed even when the pipeline fails mid-stage.

Teacher's Note

If your team runs Jenkins, take the complete Jenkinsfile from this lesson and create a new pipeline job pointing at your Ansible repository. Run it with ENVIRONMENT=staging and SKIP_TESTS=true for your first run to verify the credential injection and playbook invocation work correctly before enabling the full pipeline.

Practice Questions

1. Which Jenkins pipeline step injects credentials from the Jenkins credentials store into environment variables within a scoped block — without exposing them in the Jenkinsfile?



2. Which Jenkins declarative pipeline step pauses execution and waits for a human to click an approval button before the next stage runs?



3. In which Jenkinsfile section should you delete the .vault_pass file to ensure it is removed even when the pipeline fails mid-stage?



Quiz

1. What credential type should an SSH private key be stored as in Jenkins, and how does Jenkins make it available to Ansible?


2. A Jenkins pipeline runs Molecule tests for 4 roles sequentially in a single stage, taking 12 minutes total. How do you make them run simultaneously?


3. Ten application teams each have their own Jenkinsfile with duplicated Ansible credential handling and deployment logic. When the platform team changes the SSH key credential ID, all ten Jenkinsfiles need updating. What is the solution?


Up Next · Lesson 39

Ansible Anti-patterns

Learn the most common Ansible mistakes that experienced engineers still make — and the correct patterns to replace them with. Recognising anti-patterns is what separates maintainable automation from the kind that breaks under pressure.