Introduction

Running playbooks manually doesn't scale. CI/CD pipelines turn Ansible automation into a controlled, auditable, and repeatable process: every infrastructure change goes through linting, testing, approval, deployment, and verification. Whether you use GitLab CI, GitHub Actions, or Jenkins, the pattern is the same — lint → test → plan → approve → deploy → verify → rollback if needed.

Pipeline Stages

┌────────┐   ┌─────────┐   ┌──────────┐   ┌─────────┐   ┌────────┐   ┌────────┐
│  Lint  │──▶│  Test   │──▶│  Review  │──▶│ Deploy  │──▶│ Verify │──▶│ Report │
│        │   │         │   │          │   │         │   │        │   │        │
│ yamllint│   │ molecule│   │ approval │   │ ansible │   │ smoke  │   │ slack  │
│ ansible │   │ --check │   │ gate     │   │ playbook│   │ tests  │   │ ticket │
│ -lint   │   │ --diff  │   │          │   │         │   │        │   │        │
└────────┘   └─────────┘   └──────────┘   └─────────┘   └────────┘   └────────┘

GitHub Actions

Basic Pipeline

# .github/workflows/ansible-deploy.yml
name: Ansible Deploy
on:
  push:
    branches: [main]
    paths: ['ansible/**', 'roles/**', 'playbooks/**']
  pull_request:
    branches: [main]
    paths: ['ansible/**', 'roles/**', 'playbooks/**']

env:
  ANSIBLE_FORCE_COLOR: true
  ANSIBLE_HOST_KEY_CHECKING: false

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: |
          pip install ansible-core ansible-lint yamllint

      - name: YAML lint
        run: yamllint -c .yamllint.yml .

      - name: Ansible lint
        run: ansible-lint playbooks/ roles/

      - name: Syntax check
        run: ansible-playbook playbooks/site.yml --syntax-check

  test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: |
          pip install ansible-core molecule molecule-docker pytest-testinfra

      - name: Run Molecule tests
        run: |
          cd roles/webserver
          molecule test
        env:
          PY_COLORS: 1
          MOLECULE_DISTRO: rockylinux9

  dry-run:
    needs: test
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Ansible
        run: pip install ansible-core boto3

      - name: Run playbook in check mode
        run: |
          ansible-playbook -i inventory/production playbooks/site.yml \
            --check --diff
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

  deploy-staging:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Install Ansible
        run: pip install ansible-core boto3

      - name: Deploy to staging
        run: |
          ansible-playbook -i inventory/staging playbooks/site.yml \
            --extra-vars "deploy_version=${{ github.sha }}"
        env:
          ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}

      - name: Run smoke tests
        run: |
          ansible-playbook -i inventory/staging playbooks/smoke-tests.yml

  deploy-production:
    needs: deploy-staging
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval in GitHub
    steps:
      - uses: actions/checkout@v4

      - name: Install Ansible
        run: pip install ansible-core boto3

      - name: Deploy to production
        run: |
          ansible-playbook -i inventory/production playbooks/site.yml \
            --extra-vars "deploy_version=${{ github.sha }}"
        env:
          ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}

      - name: Verify deployment
        run: |
          ansible-playbook -i inventory/production playbooks/verify-deployment.yml

      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v1.24.0
        with:
          payload: |
            {
              "text": "${{ job.status == 'success' && '✅' || '❌' }} Production deploy ${{ job.status }}: ${{ github.sha }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

GitLab CI

# .gitlab-ci.yml
stages:
  - lint
  - test
  - staging
  - approval
  - production

variables:
  ANSIBLE_FORCE_COLOR: "true"
  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 ansible-lint yamllint boto3
    - ansible-galaxy install -r requirements.yml

lint:
  extends: .ansible-base
  stage: lint
  script:
    - yamllint -c .yamllint.yml .
    - ansible-lint playbooks/ roles/
    - ansible-playbook playbooks/site.yml --syntax-check
  rules:
    - changes:
        - "**/*.yml"
        - "**/*.yaml"
        - roles/**/*

molecule-test:
  stage: test
  image: docker:24-dind
  services:
    - docker:24-dind
  before_script:
    - apk add python3 py3-pip
    - pip install molecule molecule-docker ansible-core
  script:
    - cd roles/webserver && molecule test
    - cd ../database && molecule test
  rules:
    - changes:
        - roles/**/*

deploy-staging:
  extends: .ansible-base
  stage: staging
  script:
    - echo "$VAULT_PASSWORD" > .vault_pass
    - ansible-playbook -i inventory/staging playbooks/site.yml
        --vault-password-file .vault_pass
        --extra-vars "deploy_version=$CI_COMMIT_SHA"
    - rm -f .vault_pass
  environment:
    name: staging
    url: https://app.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

approval-gate:
  stage: approval
  script:
    - echo "Awaiting manual approval for production deployment"
  when: manual
  allow_failure: false
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  extends: .ansible-base
  stage: production
  needs: [approval-gate]
  script:
    - echo "$VAULT_PASSWORD" > .vault_pass
    - ansible-playbook -i inventory/production playbooks/site.yml
        --vault-password-file .vault_pass
        --extra-vars "deploy_version=$CI_COMMIT_SHA"
    - ansible-playbook -i inventory/production playbooks/verify-deployment.yml
    - rm -f .vault_pass
  environment:
    name: production
    url: https://app.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  when: on_success

Jenkins

Jenkinsfile

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

    environment {
        ANSIBLE_FORCE_COLOR = 'true'
        ANSIBLE_HOST_KEY_CHECKING = 'false'
    }

    options {
        timeout(time: 60, unit: 'MINUTES')
        disableConcurrentBuilds()
        buildDiscarder(logRotator(numToKeepStr: '30'))
    }

    stages {
        stage('Setup') {
            steps {
                sh '''
                    pip install ansible-core ansible-lint yamllint boto3
                    ansible-galaxy install -r requirements.yml
                '''
            }
        }

        stage('Lint') {
            steps {
                sh 'yamllint -c .yamllint.yml .'
                sh 'ansible-lint playbooks/ roles/'
                sh 'ansible-playbook playbooks/site.yml --syntax-check'
            }
        }

        stage('Test') {
            steps {
                sh '''
                    pip install molecule molecule-docker
                    cd roles/webserver && molecule test
                '''
            }
        }

        stage('Deploy Staging') {
            when { branch 'main' }
            steps {
                withCredentials([
                    string(credentialsId: 'ansible-vault-pass', variable: 'VAULT_PASS'),
                    usernamePassword(credentialsId: 'aws-creds', usernameVariable: 'AWS_ACCESS_KEY_ID', passwordVariable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    sh '''
                        echo "$VAULT_PASS" > .vault_pass
                        ansible-playbook -i inventory/staging playbooks/site.yml \
                            --vault-password-file .vault_pass \
                            --extra-vars "deploy_version=${GIT_COMMIT}"
                        rm -f .vault_pass
                    '''
                }
            }
        }

        stage('Verify Staging') {
            when { branch 'main' }
            steps {
                sh 'ansible-playbook -i inventory/staging playbooks/smoke-tests.yml'
            }
        }

        stage('Approve Production') {
            when { branch 'main' }
            steps {
                input message: 'Deploy to production?', ok: 'Deploy'
            }
        }

        stage('Deploy Production') {
            when { branch 'main' }
            steps {
                withCredentials([
                    string(credentialsId: 'ansible-vault-pass', variable: 'VAULT_PASS'),
                    usernamePassword(credentialsId: 'aws-creds', usernameVariable: 'AWS_ACCESS_KEY_ID', passwordVariable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    sh '''
                        echo "$VAULT_PASS" > .vault_pass
                        ansible-playbook -i inventory/production playbooks/site.yml \
                            --vault-password-file .vault_pass \
                            --extra-vars "deploy_version=${GIT_COMMIT}"
                        rm -f .vault_pass
                    '''
                }
            }
        }
    }

    post {
        success {
            slackSend color: 'good', message: "✅ ${env.JOB_NAME} #${env.BUILD_NUMBER} deployed successfully"
        }
        failure {
            slackSend color: 'danger', message: "❌ ${env.JOB_NAME} #${env.BUILD_NUMBER} failed"
        }
    }
}

Molecule Testing in CI

# molecule/default/molecule.yml
dependency:
  name: galaxy
driver:
  name: docker
platforms:
  - name: rhel9
    image: rockylinux:9
    pre_build_image: true
    command: /usr/sbin/init
    privileged: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
  - name: ubuntu2204
    image: ubuntu:22.04
    pre_build_image: true
    command: /usr/sbin/init
    privileged: true
provisioner:
  name: ansible
  playbooks:
    converge: converge.yml
    verify: verify.yml
verifier:
  name: ansible
# molecule/default/verify.yml
---
- name: Verify role applied correctly
  hosts: all
  tasks:
    - name: Check nginx is installed
      ansible.builtin.package:
        name: nginx
        state: present
      check_mode: true
      register: pkg
      failed_when: pkg.changed

    - name: Check nginx is running
      ansible.builtin.service:
        name: nginx
        state: started
      check_mode: true
      register: svc
      failed_when: svc.changed

    - name: Check config file exists
      ansible.builtin.stat:
        path: /etc/nginx/nginx.conf
      register: conf
      failed_when: not conf.stat.exists

Rollback Strategy

# playbooks/rollback.yml
---
- name: Emergency rollback
  hosts: "{{ target_hosts | default('webservers') }}"
  serial: "50%"
  vars:
    rollback_version: "{{ previous_version }}"
  tasks:
    - name: Deploy previous version
      ansible.builtin.include_role:
        name: deploy-app
      vars:
        app_version: "{{ rollback_version }}"

    - name: Verify rollback
      ansible.builtin.uri:
        url: "http://{{ inventory_hostname }}:8080/healthz"
        status_code: 200
      retries: 5
      delay: 5

Conclusion

Every CI/CD platform follows the same Ansible pattern: lint (yamllint + ansible-lint) → test (Molecule) → dry-run (--check --diff) → staging deploy → approval gate → production deploy → verify → notify. GitHub Actions uses environment: protection rules for approval gates, GitLab CI uses when: manual, and Jenkins uses input. Store vault passwords and cloud credentials as CI secrets, never in the repo. The key enterprise requirement is the approval gate between staging and production — manual confirmation that gives teams confidence to automate everything else.