Ansible OEL 8 DevOps · OEL 8 · CI/CD

AnsibleGitLab CI / GitHub Actions

Wiring Ansible into a real CI/CD pipeline — both GitLab CI and GitHub Actions. The 6-stage pipeline (lint → syntax → molecule → staging → manual approval → prod), secret management for vault passwords, and the runner setup that scales.

A good Ansible CI pipeline catches problems before production: broken YAML, lint failures, playbooks that don't even parse, role logic that breaks on a fresh OEL 8 container, and playbooks that work in dev but blow up in staging. Production deploys are gated behind a manual approval and run only when every earlier stage is green.

Git push → 6-stage pipeline → production deploy 1. git push developer 2. lint ansible-lint 3. syntax --syntax-check 4. molecule role tests 5. staging auto-deploy 6. prod manual approval Per stage: ~15s ~5s ~3 min ~5 min ~8 min What flows through the pipeline Source: site.yml · roles/ · inventories/dev/ · inventories/staging/ · inventories/production/ Secrets: $VAULT_PASS_DEV · $VAULT_PASS_STAGING · $VAULT_PASS_PROD (CI vars · masked) Outputs: ansible.log · molecule/test_logs/ · slack notification on failure

Catch the cheapest mistakes first. ansible-lint finds deprecated syntax, unsafe patterns (no changed_when on shell, missing name), formatting issues, and YAML errors. Ten seconds per run, saves hours later.

YAML — .ansible-lint config
---
profile: production    # production = strictest preset

skip_list:
  - role-name              # we use snake_case for our internal roles
  - meta-no-info           # internal roles don't need full Galaxy meta

warn_list:
  - experimental
  - fqcn[action-core]      # warn but don't fail when builtin modules are unprefixed

exclude_paths:
  - .cache/
  - .github/
  - molecule/

# Custom rule: every task must have name
enable_list:
  - args
  - empty-string-compare
  - no-log-password
  - no-same-owner

mock_modules:
  - community.mysql.mysql_query
  - community.proxysql.proxysql_query

Before molecule fires up containers, make sure every playbook even parses:

BASH — what syntax-check catches
# Plain syntax check on the entrypoint playbook
ansible-playbook -i inventories/dev/ site.yml --syntax-check

# Plus all the other playbooks
for pb in playbooks/*.yml; do
  ansible-playbook --syntax-check "$pb" -i inventories/dev/
done

# Compile inventory (also fails on bad YAML in inventory)
ansible-inventory -i inventories/dev/ --list >/dev/null

The slowest stage but also the most valuable. Molecule spins up a fresh OEL 8 container, runs the role, asserts it's idempotent, then runs verify tests. Covered in detail on the next page.

YAML — .gitlab-ci.yml
---
default:
  image: quay.io/ansible/creator-ee:v0.21.0
  before_script:
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - ansible-galaxy collection install -r requirements.yml

stages:
  - lint
  - syntax
  - test
  - deploy_staging
  - deploy_production

# ─── Stage 1: lint ──────────────────────────────────────────────────
ansible_lint:
  stage: lint
  script:
    - ansible-lint --profile production .
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

yaml_lint:
  stage: lint
  script:
    - yamllint -c .yamllint .
  allow_failure: true

# ─── Stage 2: syntax ────────────────────────────────────────────────
syntax_check:
  stage: syntax
  script:
    - ansible-playbook -i inventories/dev/ site.yml --syntax-check
    - ansible-inventory -i inventories/dev/ --list > /dev/null
    - ansible-inventory -i inventories/staging/ --list > /dev/null
    - ansible-inventory -i inventories/production/ --list > /dev/null

# ─── Stage 3: molecule ──────────────────────────────────────────────
molecule_test:
  stage: test
  services:
    - name: docker:dind
      alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
  parallel:
    matrix:
      - ROLE: [common, mysql, postgresql, proxysql, monitoring]
  script:
    - cd roles/$ROLE
    - molecule test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

# ─── Stage 4: deploy staging ────────────────────────────────────────
deploy_staging:
  stage: deploy_staging
  environment:
    name: staging
    url: https://staging.example.com
  before_script:
    # Pull the SSH deploy key from the CI secret store
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - echo -e "Host *\n  StrictHostKeyChecking no" > ~/.ssh/config

    # Pull the staging vault password
    - echo "$VAULT_PASS_STAGING" > .vault_pass_stg
    - chmod 600 .vault_pass_stg
  script:
    - ansible-playbook -i inventories/staging/ site.yml \
        --vault-id staging@.vault_pass_stg
  after_script:
    - rm -f .vault_pass_stg
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

# ─── Stage 5: deploy production ─────────────────────────────────────
deploy_production:
  stage: deploy_production
  environment:
    name: production
    url: https://production.example.com
  before_script:
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - echo "$VAULT_PASS_PROD" > .vault_pass_prod
    - chmod 600 .vault_pass_prod
  script:
    # check mode first — diff what WOULD change
    - ansible-playbook -i inventories/production/ site.yml \
        --vault-id prod@.vault_pass_prod \
        --check --diff > check.log
    - cat check.log

    # actual run
    - ansible-playbook -i inventories/production/ site.yml \
        --vault-id prod@.vault_pass_prod
  after_script:
    - rm -f .vault_pass_prod
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual                # human approval required
YAML — .github/workflows/ansible.yml
---
name: Ansible CI/CD

on:
  push:
    branches: [main]
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    container: quay.io/ansible/creator-ee:v0.21.0
    steps:
      - uses: actions/checkout@v4

      - name: Install collections
        run: ansible-galaxy collection install -r requirements.yml

      - name: ansible-lint
        run: ansible-lint --profile production .

  syntax:
    runs-on: ubuntu-latest
    needs: lint
    container: quay.io/ansible/creator-ee:v0.21.0
    steps:
      - uses: actions/checkout@v4

      - name: Syntax-check site.yml
        run: ansible-playbook -i inventories/dev/ site.yml --syntax-check

  molecule:
    runs-on: ubuntu-latest
    needs: lint
    strategy:
      matrix:
        role: [common, mysql, postgresql, proxysql, monitoring]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with: {{ python-version: "3.11" }}

      - name: Install Molecule
        run: |
          pip install molecule[docker] ansible-core community.docker

      - name: Run molecule test
        run: cd roles/${{{{ matrix.role }}}} && molecule test
        env:
          MOLECULE_NO_LOG: "false"

  deploy_staging:
    runs-on: ubuntu-latest
    needs: [molecule, syntax]
    if: github.ref == 'refs/heads/main'
    environment: staging        # GitHub environment with auto-approve
    container: quay.io/ansible/creator-ee:v0.21.0
    steps:
      - uses: actions/checkout@v4

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

      - name: Setup vault password
        run: |
          echo "${{{{ secrets.VAULT_PASS_STAGING }}}}" > .vault_pass_stg
          chmod 600 .vault_pass_stg

      - name: Deploy
        run: |
          ansible-playbook -i inventories/staging/ site.yml \
            --vault-id staging@.vault_pass_stg

      - name: Cleanup
        if: always()
        run: rm -f .vault_pass_stg

  deploy_production:
    runs-on: ubuntu-latest
    needs: deploy_staging
    if: github.ref == 'refs/heads/main'
    environment: production     # GitHub environment with required reviewers
    container: quay.io/ansible/creator-ee:v0.21.0
    steps:
      - uses: actions/checkout@v4

      - name: Setup SSH + vault
        run: |
          mkdir -p ~/.ssh
          echo "${{{{ secrets.SSH_PRIVATE_KEY }}}}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "${{{{ secrets.VAULT_PASS_PROD }}}}" > .vault_pass_prod
          chmod 600 .vault_pass_prod

      - name: Check-mode preview
        run: |
          ansible-playbook -i inventories/production/ site.yml \
            --vault-id prod@.vault_pass_prod \
            --check --diff

      - name: Deploy
        run: |
          ansible-playbook -i inventories/production/ site.yml \
            --vault-id prod@.vault_pass_prod

      - name: Cleanup
        if: always()
        run: rm -f .vault_pass_prod
SecretWhere it livesHow CI gets it
SSH private keyCI Secret (masked, file type)$SSH_PRIVATE_KEY → file
Vault password (per env)CI Secret (masked, scoped to env)$VAULT_PASS_PROD
AWS / cloud credentialsOIDC federation (no static keys)Assume role at runtime
Internal API tokensHashiCorp Vaultvault read via OIDC auth
⚠ Warning: Never put a vault password (or any secret) in a CI variable that ISN'T marked as masked. Once it's in the job log, it's effectively leaked. Both GitLab and GitHub mask masked secrets in logs automatically — but only if you mark them.
Pipeline stageRunner needs
Lint, syntaxTiny — any shared runner works
Molecule2 CPU, 4GB RAM, Docker socket access
DeployNetwork access to managed nodes (often via bastion). Dedicated runner.
💡 Tip: For staging and production deploys, use self-hosted runners in your VPC. The runner needs SSH access to the managed nodes — that's a network reachability requirement that public CI runners can't meet without a tunnel.
✅ Tip: CI-driven Ansible turns 'I ran the playbook last Friday' into 'every commit on main has been linted, syntax-checked, role-tested in containers, and deployed to staging — production is one approval click away'. The deploy itself becomes the boring, predictable part.