Ansible Course
Ansible in CI/CD Pipelines
In this lesson
Integrating Ansible into a CI/CD pipeline closes the loop between writing automation and shipping it reliably. Without CI/CD, Ansible changes are tested manually and deployed by whoever has the right credentials that day. With CI/CD, every change is automatically linted, tested, and deployed through a defined promotion sequence — staging before production, with gates between each environment that must pass before the next stage runs. This lesson builds complete pipeline configurations for both GitHub Actions and GitLab CI, covering the full lifecycle from a code push to a verified production deployment.
Pipeline Architecture
A well-structured Ansible CI/CD pipeline has four stages. Each stage is a gate — if it fails, the pipeline stops and the change does not proceed to production.
Stage 1 — Validate
Lint and syntax check
yamllint checks YAML formatting. ansible-lint checks for deprecated modules, missing task names, security antipatterns, and style violations. ansible-playbook --syntax-check confirms Ansible can parse every playbook. Runs in under 30 seconds — fast feedback before any execution.
Stage 2 — Test
Molecule role tests
Molecule spins up a Docker container or VM, applies each role, verifies the expected state with an Ansible verify playbook or Testinfra, then destroys the environment. Confirms each role works correctly in isolation before integration testing.
Stage 3 — Deploy staging
Apply to staging environment
Run the full playbook against the staging inventory with --check --diff first to preview changes, then apply. Run integration smoke tests against staging. Only passes if the application is healthy after deployment.
Stage 4 — Deploy production
Apply to production with manual gate
Production deployment requires a manual approval — a human reviews the staging results and approves before the pipeline proceeds. After approval, the same playbook runs against the production inventory. Post-deployment smoke tests confirm the release.
GitHub Actions — Complete Pipeline
GitHub Actions is the most common CI/CD platform for Ansible projects hosted on GitHub. The workflow below implements all four pipeline stages with the vault password injected from a GitHub repository secret.
# .github/workflows/ansible.yml
name: Ansible CI/CD Pipeline
on:
push:
branches: [main, staging]
pull_request:
branches: [main]
env:
ANSIBLE_FORCE_COLOR: "1"
ANSIBLE_HOST_KEY_CHECKING: "False"
jobs:
# ─────────────────────────────────────────────
# Stage 1: Lint and syntax check
# ─────────────────────────────────────────────
validate:
name: Lint & Syntax Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Ansible and linting tools
run: |
pip install ansible ansible-lint yamllint
- name: Install Galaxy requirements
run: ansible-galaxy install -r requirements.yml
- name: Run yamllint
run: yamllint .
- name: Run ansible-lint
run: ansible-lint
- name: Syntax check all playbooks
run: |
for playbook in *.yml playbooks/*.yml; do
echo "Checking: $playbook"
ansible-playbook "$playbook" --syntax-check \
-i inventory/staging/
done
# ─────────────────────────────────────────────
# Stage 2: Molecule role tests
# ─────────────────────────────────────────────
molecule:
name: Molecule Tests
runs-on: ubuntu-latest
needs: validate
strategy:
matrix:
role: [common, nginx, postgresql, hardening]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Molecule and Docker driver
run: pip install ansible molecule molecule-plugins[docker] docker
- name: Run Molecule tests for ${{ matrix.role }}
run: |
cd roles/${{ matrix.role }}
molecule test
env:
MOLECULE_DISTRO: ubuntu2204
# ─────────────────────────────────────────────
# Stage 3: Deploy to staging (on push to main)
# ─────────────────────────────────────────────
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: molecule
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: staging
steps:
- uses: actions/checkout@v4
- name: Install Ansible
run: pip install ansible
- name: Install Galaxy requirements
run: ansible-galaxy install -r requirements.yml
- name: Write vault password
run: echo "${{ secrets.VAULT_PASSWORD }}" > .vault_pass
- name: Write SSH private key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Preview changes (check + diff)
run: |
ansible-playbook site.yml \
-i inventory/staging/ \
--vault-password-file .vault_pass \
--check --diff
- name: Apply to staging
run: |
ansible-playbook site.yml \
-i inventory/staging/ \
--vault-password-file .vault_pass
- name: Run smoke tests
run: |
ansible-playbook smoke_tests.yml \
-i inventory/staging/ \
--vault-password-file .vault_pass
- name: Clean up secrets
if: always()
run: rm -f .vault_pass ~/.ssh/id_ed25519
# ─────────────────────────────────────────────
# Stage 4: Deploy to production (manual approval)
# ─────────────────────────────────────────────
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: deploy-staging
environment: production # GitHub environment with required reviewers
steps:
- uses: actions/checkout@v4
- name: Install Ansible
run: pip install ansible
- name: Install Galaxy requirements
run: ansible-galaxy install -r requirements.yml
- name: Write vault password
run: echo "${{ secrets.VAULT_PASSWORD_PROD }}" > .vault_pass
- name: Write SSH private key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY_PROD }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Apply to production
run: |
ansible-playbook site.yml \
-i inventory/production/ \
--vault-password-file .vault_pass
- name: Run production smoke tests
run: |
ansible-playbook smoke_tests.yml \
-i inventory/production/ \
--vault-password-file .vault_pass
- name: Clean up secrets
if: always()
run: rm -f .vault_pass ~/.ssh/id_ed25519
Key design decisions
Each stage depends on (needs:)
the previous — a lint failure stops Molecule tests from running; a Molecule failure
stops staging deployment. The Molecule job uses a matrix to test all four roles in
parallel rather than sequentially. The environment: production setting
links the job to a GitHub Environment with required reviewers — the pipeline pauses
and waits for a human to approve before proceeding. Secrets are written to disk,
used, then deleted in an if: always() step that runs even if the job
fails.
GitLab CI — Complete Pipeline
GitLab CI uses a .gitlab-ci.yml
file at the repo root. The pattern is identical to GitHub Actions but uses GitLab
environments, protected variables, and the native manual job gate for production.
# .gitlab-ci.yml
image: python:3.12-slim
stages:
- validate
- test
- deploy-staging
- deploy-production
variables:
ANSIBLE_FORCE_COLOR: "1"
ANSIBLE_HOST_KEY_CHECKING: "False"
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
cache:
paths:
- .pip-cache/
before_script:
- pip install ansible ansible-lint yamllint molecule molecule-plugins[docker]
- ansible-galaxy install -r requirements.yml
# ─── Stage 1: Validate ───────────────────────────────────────
lint:
stage: validate
script:
- yamllint .
- ansible-lint
- ansible-playbook site.yml --syntax-check -i inventory/staging/
# ─── Stage 2: Molecule tests ─────────────────────────────────
.molecule-template: &molecule-template
stage: test
needs: [lint]
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
molecule-common:
<<: *molecule-template
script:
- cd roles/common && molecule test
molecule-nginx:
<<: *molecule-template
script:
- cd roles/nginx && molecule test
molecule-postgresql:
<<: *molecule-template
script:
- cd roles/postgresql && molecule test
# ─── Stage 3: Deploy staging ─────────────────────────────────
deploy-staging:
stage: deploy-staging
needs: [molecule-common, molecule-nginx, molecule-postgresql]
environment:
name: staging
url: https://staging.example.com
only:
- main
script:
- echo "$VAULT_PASSWORD" > .vault_pass
- echo "$SSH_PRIVATE_KEY" | install -m 600 /dev/stdin ~/.ssh/id_ed25519
- ansible-playbook site.yml
-i inventory/staging/
--vault-password-file .vault_pass
--check --diff
- ansible-playbook site.yml
-i inventory/staging/
--vault-password-file .vault_pass
- ansible-playbook smoke_tests.yml
-i inventory/staging/
--vault-password-file .vault_pass
after_script:
- rm -f .vault_pass ~/.ssh/id_ed25519
# ─── Stage 4: Deploy production (manual gate) ────────────────
deploy-production:
stage: deploy-production
needs: [deploy-staging]
environment:
name: production
url: https://example.com
when: manual # pipeline pauses here — requires a human to click Play
only:
- main
script:
- echo "$VAULT_PASSWORD_PROD" > .vault_pass
- echo "$SSH_PRIVATE_KEY_PROD" | install -m 600 /dev/stdin ~/.ssh/id_ed25519
- ansible-playbook site.yml
-i inventory/production/
--vault-password-file .vault_pass
- ansible-playbook smoke_tests.yml
-i inventory/production/
--vault-password-file .vault_pass
after_script:
- rm -f .vault_pass ~/.ssh/id_ed25519
The Smoke Tests Playbook
Both pipelines call a
smoke_tests.yml playbook after each deployment. This is a lightweight
integration test — not a full test suite — that confirms the application is alive
and serving expected responses before the pipeline marks the stage as passed.
# smoke_tests.yml — quick post-deployment verification
---
- name: Post-deployment smoke tests
hosts: webservers
gather_facts: false
tasks:
- name: Wait for application to respond
ansible.builtin.uri:
url: "https://{{ inventory_hostname }}/health"
status_code: 200
validate_certs: true
retries: 10
delay: 6
until: health.status == 200
register: health
- name: Confirm correct version is deployed
ansible.builtin.uri:
url: "https://{{ inventory_hostname }}/version"
return_content: true
register: version_response
- name: Assert version matches expected
ansible.builtin.assert:
that:
- app_version in version_response.content
fail_msg: >
Version mismatch! Expected {{ app_version }},
got: {{ version_response.content }}
- name: Confirm database connectivity
ansible.builtin.uri:
url: "https://{{ inventory_hostname }}/healthz/db"
status_code: 200
register: db_health
- name: Record smoke test result
ansible.builtin.debug:
msg: >
SMOKE TESTS PASSED — {{ inventory_hostname }}
running {{ app_version }}, DB healthy
ansible-lint Configuration
Commit an .ansible-lint
configuration file to the repository so every engineer and the CI pipeline use
identical rules. This prevents "passes locally, fails in CI" inconsistencies.
# .ansible-lint
profile: production # strictest built-in profile
# Exclude paths that should not be linted
exclude_paths:
- .git/
- molecule/
- .github/
# Rules to skip (with justification)
skip_list:
- yaml[line-length] # long lines acceptable in task names and messages
# Warn but do not fail on these rules
warn_list:
- experimental
# Enforce fully-qualified module names (ansible.builtin.package not just package)
use_default_rules: true
Pipeline Best Practices
What makes an Ansible pipeline production-grade
.vault_pass and SSH key files in an if: always() / after_script: block — runs even if the job fails. Prevent credential files from persisting on the runner between jobs.
requirements.txt. Unpinned dependencies produce different results on different runs — the hardest class of CI bug to diagnose.
Never Store SSH Keys or Vault Passwords as Plaintext in the Repository
The most common CI/CD security mistake is committing a private key or vault password to the repository — even temporarily — to "make the pipeline work quickly." Any secret committed to Git is permanently exposed, including to everyone who has ever cloned the repository. Always store credentials as encrypted CI/CD secrets (GitHub repository secrets, GitLab protected variables) and inject them at runtime via environment variables. The pipeline examples in this lesson demonstrate the correct pattern.
Key Takeaways
if: always().
/health and asserts the correct version is the minimum bar.
when: manual. No automated system should deploy to production without human sign-off.
Teacher's Note
Start with the validate stage only — add the GitHub Actions workflow file with just the yamllint and ansible-lint steps. Get that green. Then add Molecule. Then staging. Build the pipeline incrementally the same way you build a role — one tested layer at a time.
Practice Questions
1. A GitHub Actions job writes a vault password to .vault_pass at the start. Which condition on the cleanup step ensures the file is deleted even if the playbook fails?
2. In GitLab CI, which job attribute causes the pipeline to pause and wait for a human to explicitly click Play before the production deployment stage runs?
3. In GitHub Actions, which job keyword creates a dependency so that the staging deploy job only runs after the Molecule test job has passed?
Quiz
1. A pipeline runs Molecule tests for 5 roles sequentially, each taking 3 minutes — a total of 15 minutes just for tests. What is the fastest way to reduce this?
2. A pipeline needs a vault password and SSH private key to deploy. Where should these be stored and how should they be used?
3. The staging deployment job runs the playbook and it completes with failed=0. Why must a smoke test still run afterwards?
Up Next · Lesson 38
Ansible with Jenkins
Build Ansible-powered pipelines in Jenkins — the Ansible plugin, Jenkinsfile declarative pipelines, parameterised builds, shared libraries, and integrating Ansible into an existing Jenkins-based delivery workflow.