Introduction

Ansible is a natural fit for the deployment stage of CI/CD pipelines. After your code is built and tested, Ansible handles the infrastructure: provisioning servers, deploying artifacts, running migrations, and validating health. This guide shows integration patterns for the three most popular CI/CD platforms.

GitHub Actions

# .github/workflows/deploy.yml
name: Deploy with Ansible

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options: [staging, production]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.event.inputs.environment || 'staging' }}

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install Ansible
        run: |
          pip install ansible-core==2.20.0 jmespath
          ansible-galaxy collection install -r collections/requirements.yml

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts

      - name: Create vault password file
        run: echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault_pass
        
      - name: Run playbook
        env:
          ANSIBLE_HOST_KEY_CHECKING: "false"
        run: |
          ansible-playbook site.yml \
            -i inventory/${{ github.event.inputs.environment || 'staging' }} \
            --vault-password-file .vault_pass \
            -e "app_version=${{ github.sha }}"

      - name: Cleanup
        if: always()
        run: rm -f .vault_pass ~/.ssh/id_ed25519

With Execution Environments

      - name: Run with ansible-navigator
        run: |
          pip install ansible-navigator
          ansible-navigator run site.yml \
            --eei ghcr.io/myorg/ansible-ee:latest \
            -i inventory/staging \
            -m stdout

GitLab CI

# .gitlab-ci.yml
stages:
  - lint
  - deploy-staging
  - deploy-production

variables:
  ANSIBLE_HOST_KEY_CHECKING: "false"
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"

.ansible-base:
  image: python:3.12-slim
  before_script:
    - pip install ansible-core==2.20.0 jmespath
    - ansible-galaxy collection install -r collections/requirements.yml
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
    - chmod 600 ~/.ssh/id_ed25519
    - echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass
  after_script:
    - rm -f .vault_pass ~/.ssh/id_ed25519

lint:
  stage: lint
  image: python:3.12-slim
  script:
    - pip install ansible-lint
    - ansible-lint site.yml

deploy-staging:
  stage: deploy-staging
  extends: .ansible-base
  script:
    - ansible-playbook site.yml
        -i inventory/staging
        --vault-password-file .vault_pass
        -e "app_version=${CI_COMMIT_SHA}"
  environment:
    name: staging
    url: https://app.example.com
  only:
    - main

deploy-production:
  stage: deploy-production
  extends: .ansible-base
  script:
    - ansible-playbook site.yml
        -i inventory/production
        --vault-password-file .vault_pass
        -e "app_version=${CI_COMMIT_TAG}"
  environment:
    name: production
    url: https://example.com
  only:
    - tags
  when: manual  # Require manual approval

Jenkins

// Jenkinsfile
pipeline {
    agent any

    parameters {
        choice(name: 'ENVIRONMENT', choices: ['staging', 'production'])
        string(name: 'APP_VERSION', defaultValue: '', description: 'Version to deploy')
    }

    environment {
        ANSIBLE_HOST_KEY_CHECKING = 'false'
    }

    stages {
        stage('Lint') {
            steps {
                sh '''
                    python3 -m venv venv
                    . venv/bin/activate
                    pip install ansible-lint
                    ansible-lint site.yml
                '''
            }
        }

        stage('Deploy') {
            steps {
                withCredentials([
                    sshUserPrivateKey(credentialsId: 'deploy-key', keyFileVariable: 'SSH_KEY'),
                    string(credentialsId: 'vault-password', variable: 'VAULT_PASS')
                ]) {
                    sh """
                        . venv/bin/activate
                        pip install ansible-core==2.20.0
                        ansible-galaxy collection install -r collections/requirements.yml

                        echo "\$VAULT_PASS" > .vault_pass

                        ansible-playbook site.yml \\
                            -i inventory/${params.ENVIRONMENT} \\
                            --vault-password-file .vault_pass \\
                            --private-key \$SSH_KEY \\
                            -e "app_version=${params.APP_VERSION ?: env.GIT_COMMIT}"

                        rm -f .vault_pass
                    """
                }
            }
        }
    }

    post {
        failure {
            slackSend(color: 'danger', message: "Deploy failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}")
        }
        success {
            slackSend(color: 'good', message: "Deployed ${params.APP_VERSION} to ${params.ENVIRONMENT}")
        }
    }
}

Secrets Management

# Pattern 1: CI/CD secrets → vault password → encrypted vars
# Store ANSIBLE_VAULT_PASSWORD in CI/CD secrets
# All other secrets encrypted in vault files

# Pattern 2: CI/CD secrets → extra vars (simple projects)
ansible-playbook site.yml -e "db_password=$DB_PASSWORD"

# Pattern 3: External secret store
# HashiCorp Vault lookup
- name: Get secret from Vault
  ansible.builtin.set_fact:
    db_password: "{{ lookup('hashi_vault', 'secret/data/myapp:db_password') }}"

Pipeline Best Practices

# ✅ Always lint before deploy
# ✅ Deploy to staging before production
# ✅ Require manual approval for production
# ✅ Pin Ansible and collection versions
# ✅ Use --check --diff in PR pipelines
# ✅ Clean up secrets in post/after_script
# ✅ Pass commit SHA as app_version for traceability
# ✅ Use Execution Environments for reproducibility

Dry-Run in Pull Requests

# GitHub Actions — PR check
on:
  pull_request:
    branches: [main]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ansible-core ansible-lint
      - run: ansible-lint site.yml
      - run: ansible-playbook site.yml --check --diff -i inventory/staging

Common Mistakes

# ❌ Not pinning Ansible version
pip install ansible  # Gets latest — may break!

# ✅ Pin versions
pip install ansible-core==2.20.0

# ❌ SSH key in repo
# .ssh/deploy_key committed to git

# ✅ SSH key in CI/CD secrets, injected at runtime

# ❌ Running against production on every push
on:
  push:
    branches: [main]
# Deploys to prod on every merge!

# ✅ Use environments + manual approval for production

Conclusion

GitHub Actions: Use actions/checkout + pip install + SSH key from secrets. GitLab CI: Use .extends for shared setup, when: manual for production gates. Jenkins: Use withCredentials for secrets, parameterized builds for environment selection. All three follow the same pattern: lint → dry-run (staging) → deploy (staging) → manual gate → deploy (production). Pin your Ansible version, encrypt secrets with Vault, and always clean up credentials in post-run steps.