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.
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.
---
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:
# 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.
---
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
---
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
| Secret | Where it lives | How CI gets it |
|---|---|---|
| SSH private key | CI Secret (masked, file type) | $SSH_PRIVATE_KEY → file |
| Vault password (per env) | CI Secret (masked, scoped to env) | $VAULT_PASS_PROD |
| AWS / cloud credentials | OIDC federation (no static keys) | Assume role at runtime |
| Internal API tokens | HashiCorp Vault | vault read via OIDC auth |
| Pipeline stage | Runner needs |
|---|---|
| Lint, syntax | Tiny — any shared runner works |
| Molecule | 2 CPU, 4GB RAM, Docker socket access |
| Deploy | Network access to managed nodes (often via bastion). Dedicated runner. |