Ansible Lesson 37 – Ansible in CI/CD Pipelines | Dataplexa
Section III · Lesson 37

Ansible in CI/CD Pipelines

In this lesson

Pipeline architecture GitHub Actions GitLab CI Lint & test stages Environment promotion

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

Separate secrets per environment Staging and production use different vault passwords and SSH keys — stored as separate CI/CD secrets. A staging key compromise cannot be used against production.
Always clean up secrets Delete .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.
Pin all versions Pin the Ansible version, Galaxy role versions, and Python package versions in requirements.txt. Unpinned dependencies produce different results on different runs — the hardest class of CI bug to diagnose.
Run --check --diff before apply Run the dry-run check as a separate step before applying. If the diff contains unexpected changes, the job can be halted before any state change — the pipeline-level equivalent of manual review.
Manual gate for production Never auto-deploy to production. Always require a human to review staging results and explicitly approve the production stage — even for small changes.

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

Structure pipelines as four stages: validate → test → staging → production — each stage is a gate that must pass before the next runs. Lint failures never reach staging; staging failures never reach production.
Inject secrets from CI/CD platform secret storage — GitHub repository secrets or GitLab protected variables. Write them to files at job start, use them, delete them in if: always().
Run Molecule in a matrix across all roles simultaneously — parallel role testing dramatically reduces total pipeline time compared to sequential execution.
Always run smoke tests after every deployment — a playbook completing successfully does not mean the application is working. A smoke test that polls /health and asserts the correct version is the minimum bar.
Require manual approval for production — use GitHub Environments with required reviewers or GitLab 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.