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
Related Articles
- Ansible Best Practices Guide
- Ansible-Lint Guide
- Ansible + Terraform Pipeline
- Ansible Automation Platform Guide
- Ansible Roles for Reusable Automation
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.