Ansible in CI/CD: Vault Passwords, Check Mode Gates, and Molecule Before Merge
Run Ansible in Jenkins and GitHub Actions with vault secrets, --check mode dry-run gates, and Molecule testing.
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
Store vault passwords in CI secrets (e.g., Jenkins Credentials or GitHub Actions secrets) and pass via --vault-password-file or env var.
Limit Ansible to specific hosts in CI using --limit with inventory hostnames or group names from env vars.
Use --check --diff in a separate CI job stage as a dry-run gate; fail the pipeline if check mode reveals changes.
Run Molecule scenarios (e.g., molecule test) in PR checks before merge to validate playbooks against Docker or Vagrant.
In Jenkins Declarative Pipeline, use ansible-playbook inside a sh step; for GitHub Actions, use the ansible/ansible-playbook-action.
Never hardcode vault passwords in playbooks or inventory; always inject via CI secrets and use --vault-id for multiple vaults.
Ansible's --check mode skips tasks that modify state but still runs facts and conditionals; beware of side effects.
Use ansible-lint and ansible-playbook --syntax-check as early pipeline gates to catch errors fast.
Imagine you're a chef with a recipe book for setting up a restaurant kitchen. You have secret ingredients (like vault passwords) that only you know. Now, you want to let junior chefs use your recipes, but you don't want them to see the secret ingredients or accidentally set the kitchen on fire. Ansible in CI/CD is like having a automated assistant that: (1) hides the secret ingredients in a locked safe (CI secrets), (2) does a 'pretend cook' first to check if anything would burn (--check mode), and (3) tests the recipe on a small countertop (Molecule) before the real kitchen. The assistant also ensures the junior chefs only work on specific stations (--limit) and not the whole kitchen.
A few years ago, I was on-call for a critical infrastructure deployment. A junior engineer had merged a PR that ran an Ansible playbook against all production hosts instead of the intended canary group. The playbook accidentally restarted a database service across 200 servers. The --limit flag was missing in the CI pipeline. We learned the hard way that CI/CD for Ansible needs explicit host scoping and dry-run gates.
Historically, Ansible was run ad-hoc from laptops. As teams grew, they needed repeatable, auditable deployments. CI/CD pipelines became the standard way to run playbooks, but they introduced new failure modes: secrets leaking, running against wrong hosts, and no validation before merge.
This article covers how to run Ansible in Jenkins Declarative Pipelines and GitHub Actions safely. You'll learn to store vault passwords in CI secrets, limit hosts, use --check mode as a gate, and test with Molecule before merge. These patterns come from real production incidents and have saved my teams countless times.
We'll dive into specific code examples for Jenkinsfile and GitHub Actions workflows, with exact flags and secret injection methods. By the end, you'll have a production-grade CI/CD pipeline for Ansible that prevents the most common disasters.
Jenkins Declarative Pipeline: Ansible with Vault Secrets
In Jenkins, use the Declarative Pipeline syntax with a sh step to run ansible-playbook. Store vault passwords as Jenkins Credentials (Secret text) and inject them via withCredentials.
``groovy pipeline { agent any environment { ANSIBLE_HOST_KEY_CHECKING = 'False' ANSIBLE_VAULT_PASSWORD_FILE = "${WORKSPACE}/vault-pass" } stages { stage('Create vault password file') { steps { withCredentials([string(credentialsId: 'ansible-vault-password', variable: 'VAULT_PASS')]) { sh 'echo "$VAULT_PASS" > ${ANSIBLE_VAULT_PASSWORD_FILE}' } } } stage('Run Ansible') { steps { sh 'ansible-playbook -i inventory/production.yml site.yml --limit "${HOSTS}" --vault-password-file ${ANSIBLE_VAULT_PASSWORD_FILE}' } } } post { always { sh 'rm -f ${ANSIBLE_VAULT_PASSWORD_FILE}' } } } ``
Gotcha: Never echo the vault password into the pipeline log. Use withCredentials to mask it. Also, clean up the password file in post.always.
For multiple vault IDs, use --vault-id: `` ansible-playbook --vault-id dev@${ANSIBLE_VAULT_PASSWORD_FILE} --vault-id prod@${ANSIBLE_VAULT_PASSWORD_FILE2} ``
chmod 600 ${ANSIBLE_VAULT_PASSWORD_FILE}. Otherwise, other processes on the same agent could read it.cat on that file and the password leaked into logs. We now always use chmod 600 and delete the file immediately after use.GitHub Actions: ansible-playbook-action and Secrets
GitHub Actions has a community action ansible/ansible-playbook-action that simplifies running playbooks. Store vault passwords as repository secrets and pass them via env.
``yaml name: Ansible Deploy on: push: branches: [ main ] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Ansible playbook uses: ansible/ansible-playbook-action@v1 with: playbook: site.yml inventory: inventory/production.yml limit: '${{ vars.HOSTS }}' vault_password: '${{ secrets.ANSIBLE_VAULT_PASSWORD }}' options: | --check --diff ``
Important: The action writes the vault password to a temporary file and passes it via --vault-password-file. It also supports vault_password_file input if you have a file.
If you need more control, use a raw run step: ``yaml - name: Run Ansible env: ANSIBLE_VAULT_PASSWORD_FILE: /tmp/vault-pass run: | echo '${{ secrets.ANSIBLE_VAULT_PASSWORD }}' > $ANSIBLE_VAULT_PASSWORD_FILE chmod 600 $ANSIBLE_VAULT_PASSWORD_FILE ansible-playbook -i inventory/production.yml site.yml --limit "${{ vars.HOSTS }}" ``
Gotcha: The ansible/ansible-playbook-action does not support multiple vault IDs natively. Use the raw run step with --vault-id for that.
echo carefully.ACTIONS_STEP_DEBUG: false in the workflow.ansible/ansible-playbook-action for simplicity, but switch to raw run for advanced needs like multiple vault IDs.Limiting Hosts in CI: The --limit Safety Net
The --limit flag is your best friend in CI. Always use it with a parameter or variable that defaults to a safe group (e.g., canary or staging). Never allow an empty limit.
Jenkins example with input parameter: ``groovy parameters { string(name: 'HOSTS', defaultValue: 'canary', description: 'Host pattern for --limit') } stage('Validate HOSTS') { steps { sh ''' if [ -z "${HOSTS}" ]; then echo "ERROR: HOSTS parameter cannot be empty" exit 1 fi # Optional: validate against known groups ansible-inventory -i inventory/production.yml --list | jq -e '."'"${HOSTS}"'"' || { echo "ERROR: ${HOSTS} not found in inventory" exit 1 } ''' } } ``
GitHub Actions with environment variable: ``yaml env: HOSTS: ${{ github.event_name == 'pull_request' && 'staging' || 'canary' }} ``
Gotcha: Ansible's --limit accepts patterns like hostname, group:&group2, !group3. But in CI, avoid complex patterns that may be misinterpreted. Stick to simple hostnames or group names.
Multiple limits: Use --limit multiple times or comma-separated: --limit 'group1,group2'.
Using --check Mode as a Dry-Run Gate
A common pattern is to run --check --diff in a separate stage and fail the pipeline if it detects changes. This acts as a dry-run gate before actual deployment.
Jenkins example: ``groovy stage('Dry Run (Check Mode)') { steps { sh ''' ansible-playbook -i inventory/production.yml site.yml --limit "${HOSTS}" --check --diff \ --vault-password-file ${ANSIBLE_VAULT_PASSWORD_FILE} 2>&1 | tee check_output.log # Fail if any changes detected if grep -q 'changed=' check_output.log; then echo "ERROR: Check mode detected changes. Pipeline aborted." exit 1 fi ''' } } ``
GitHub Actions example: ``yaml - name: Check Mode run: | ansible-playbook -i inventory/production.yml site.yml --limit "${{ vars.HOSTS }}" --check --diff 2>&1 | tee check.log if grep -q 'changed=' check.log; then echo "Changes detected in check mode. Failing." exit 1 fi ``
Gotcha: --check mode is not perfect. Some modules (like command or shell) do not support check mode and will report changed even if they wouldn't change anything. Use check_mode: no on such tasks or handle them specially.
Alternative: Use --diff only, without --check, to see what would change, but that's riskier.
command, shell, raw, script do not support check mode. They will always report changed in check mode. Consider using changed_when: false or check_mode: no for those tasks.command to restart a service. In check mode, it always reported changed, causing the gate to fail. We added changed_when: false to that task and used a separate handler for restarts.--check --diff as a gate, but be aware of modules that don't support check mode. Adjust tasks with changed_when or check_mode: no.Testing with Molecule Before Merge
Molecule is the standard testing framework for Ansible roles. Run it in CI on every PR to validate playbooks against Docker containers or VMs before merge.
Molecule scenario example (molecule/default/molecule.yml): ``yaml --- provisioner: name: ansible playbooks: converge: converge.yml platforms: - name: instance image: geerlingguy/docker-ubuntu2204-ansible:latest pre_build_image: true verifier: name: ansible ``
GitHub Actions workflow for Molecule: ``yaml name: Molecule Test on: [pull_request] jobs: molecule: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install dependencies run: | pip install molecule molecule-plugins[docker] ansible-lint - name: Run Molecule run: molecule test env: ANSIBLE_VAULT_PASSWORD_FILE: /dev/null # or provide a test vault password ``
Jenkins pipeline for Molecule: ``groovy stage('Molecule Test') { steps { sh ''' pip install molecule molecule-plugins[docker] ansible-lint molecule test ''' } } ``
Gotcha: Molecule requires Docker or Vagrant. Ensure the CI runner has Docker installed and the user has permissions. Use molecule docker driver for most CI environments.
molecule/ubuntu2204, molecule/centos9) to test against different OS versions. Run them in parallel in CI.molecule test in CI on every PR to catch playbook errors early. Use Docker-based scenarios for speed.Secrets Management: Vault Passwords and API Tokens
Beyond vault passwords, Ansible playbooks often need API tokens (e.g., for cloud modules). Inject them via CI secrets and use Ansible's environment or vars.
Jenkins example with multiple secrets: ``groovy withCredentials([ string(credentialsId: 'ansible-vault-password', variable: 'VAULT_PASS'), string(credentialsId: 'aws-access-key', variable: 'AWS_ACCESS_KEY_ID'), string(credentialsId: 'aws-secret-key', variable: 'AWS_SECRET_ACCESS_KEY') ]) { sh ''' ansible-playbook -i inventory/production.yml site.yml \ --vault-password-file <(echo "$VAULT_PASS") \ -e aws_access_key="$AWS_ACCESS_KEY_ID" \ -e aws_secret_key="$AWS_SECRET_ACCESS_KEY" ''' } ``
GitHub Actions example: ``yaml - name: Run Ansible env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: | ansible-playbook -i inventory/production.yml site.yml \ --vault-password-file <(echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}") \ -e aws_access_key="$AWS_ACCESS_KEY_ID" \ -e aws_secret_key="$AWS_SECRET_ACCESS_KEY" ``
Gotcha: Using process substitution <() may not work in all shells (e.g., some CI environments use sh instead of bash). Use a temp file instead for portability.
sh (dash) which doesn't support <(). Use a temp file: echo "$VAULT_PASS" > /tmp/vault-pass && chmod 600 /tmp/vault-pass.sh. We switched to a temp file and it worked consistently.Syntax Checks and Linting as Early Gates
Before running any playbook, validate syntax and style. Add these as separate pipeline stages to fail fast.
Jenkins stages: ``groovy stage('Syntax Check') { steps { sh 'ansible-playbook --syntax-check -i inventory/staging.yml site.yml' } } stage('Lint') { steps { sh 'ansible-lint site.yml' } } ``
GitHub Actions steps: ``yaml - name: Syntax Check run: ansible-playbook --syntax-check -i inventory/staging.yml site.yml - name: Lint run: ansible-lint site.yml ``
ansible-lint configuration (.ansible-lint): ``yaml --- exclude_paths: - .git/ - molecule/ parseable: true quiet: true ``
Gotcha: ansible-lint may have different rules across versions. Pin the version in requirements.txt or use a container image.
ansible-lint on all YAML files, not just playbooks. Use ansible-lint . to check roles, tasks, and handlers.--syntax-check and ansible-lint as early pipeline stages to catch errors before any execution.Handling Multiple Environments in CI
CI pipelines often deploy to multiple environments (dev, staging, prod). Use environment-specific inventories and variables.
Jenkins with environment matrix: ``groovy pipeline { parameters { choice(name: 'ENV', choices: ['dev', 'staging', 'prod'], description: 'Target environment') } stages { stage('Deploy') { steps { sh "ansible-playbook -i inventory/${ENV}.yml site.yml --limit '${HOSTS}'" } } } } ``
GitHub Actions with environment-specific secrets: ``yaml jobs: deploy: runs-on: ubuntu-latest environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }} steps: - uses: actions/checkout@v4 - name: Run Ansible run: ansible-playbook -i inventory/${{ vars.ENV }}.yml site.yml ``
Gotcha: Environment-specific secrets in GitHub Actions require the environment to be defined in the repository settings. Use environment: to scope secrets.
inventory/dev.yml, inventory/prod.yml) with appropriate host groups and variables.Error Handling and Rollback Strategies
Ansible playbooks can fail mid-deployment. Implement error handling and rollback in CI.
Jenkins with try-catch: ``groovy stage('Deploy') { steps { script { try { sh 'ansible-playbook site.yml --limit "${HOSTS}"' } catch (Exception e) { echo "Deployment failed, initiating rollback..." sh 'ansible-playbook rollback.yml --limit "${HOSTS}"' throw e } } } } ``
GitHub Actions with rollback job: ``yaml jobs: deploy: runs-on: ubuntu-latest steps: - run: ansible-playbook site.yml --limit "${{ vars.HOSTS }}" rollback: if: ``failure() needs: deploy runs-on: ubuntu-latest steps: - run: ansible-playbook rollback.yml --limit "${{ vars.HOSTS }}"
Gotcha: Rollback playbooks must be idempotent and tested. They should restore previous state, not just reverse changes.
Parallel Execution and Idempotency
Running Ansible on multiple hosts in parallel speeds up deployments, but requires idempotent playbooks.
Jenkins with forks: ``groovy sh 'ansible-playbook -i inventory/production.yml site.yml --forks 10 --limit "${HOSTS}"' ``
GitHub Actions with forks: ``yaml run: ansible-playbook -i inventory/production.yml site.yml --forks 10 --limit "${{ vars.HOSTS }}" ``
Idempotency check: Use --diff to verify no changes on second run. `` ansible-playbook site.yml --check --diff ``
Gotcha: Parallel execution can cause race conditions if tasks modify shared resources (e.g., a database). Use serial or throttle in playbook to limit concurrency.
ok=... changed=0. If not, fix tasks that are not idempotent.file module, but the second run always showed 'changed' because of a missing state: directory. Idempotency testing caught it.--forks for speed, but ensure playbooks are idempotent. Test by running twice.Audit Logging and Notifications
CI pipelines should log all Ansible runs and notify teams on failures.
Jenkins with Slack notification: ``groovy post { failure { slackSend( channel: '#infra-alerts', color: 'danger', message: "Ansible deployment failed: ${env.JOB_NAME} ${env.BUILD_NUMBER} (<${env.BUILD_URL}|Open>)" ) } success { slackSend( channel: '#infra-deployments', color: 'good', message: "Ansible deployment succeeded: ${env.JOB_NAME} ${env.BUILD_NUMBER}" ) } } ``
GitHub Actions with Slack notification using action: ``yaml - name: Notify Slack if: ``always() uses: slackapi/slack-github-action@v1.24.0 with: payload: | { "channel": "#infra-alerts", "text": "Ansible deployment ${{ job.status }}" } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Store Ansible logs as artifacts: ``yaml - name: Upload logs uses: actions/upload-artifact@v4 with: name: ansible-logs path: /tmp/ansible.log ``
ansible.posix.log_plays.Advanced: Dynamic Inventory from CI Variables
Use CI environment variables to dynamically generate inventory. This is useful for ephemeral environments.
Jenkins with scripted inventory: ``groovy stage('Generate Inventory') { steps { sh ''' cat > inventory/dynamic.yml <<EOF all: hosts: ${HOSTNAME}: ansible_host: ${IP} EOF ''' } } stage('Run Ansible') { steps { sh 'ansible-playbook -i inventory/dynamic.yml site.yml' } } ``
GitHub Actions with dynamic inventory: ``yaml - name: Generate inventory run: | cat > inventory/dynamic.yml <<EOF all: hosts: ${{ vars.HOSTNAME }}: ansible_host: ${{ vars.IP }} EOF - name: Run playbook run: ansible-playbook -i inventory/dynamic.yml site.yml ``
Gotcha: Be careful with YAML injection if variables contain special characters. Use | to escape.
ansible.builtin.template to generate inventory safely.template module to generate inventory from a Jinja2 template.add_host module instead of manual YAML generation.The Missing --limit Disaster
restart postgresql as a handler.HOSTS defaulted to the canary group, but it was empty, so Ansible targeted all hosts.--limit $HOSTS where HOSTS was an empty string because the input parameter wasn't required and defaulted to ''. Ansible treats empty --limit as no limit, so it ran on all hosts in inventory.HOSTS is empty: if [[ -z "$HOSTS" ]]; then echo 'ERROR: HOSTS cannot be empty'; exit 1; fi. Also added a required parameter with a default of 'canary'.- Always validate that --limit is non-empty and matches expected host patterns.
- Use parameter validation in CI and consider a default safe group like 'canary'.
--check mode was accidentally left enabled. Also verify the inventory file is correct and hosts match the limit. Run ansible-inventory --list to debug.--vault-password-file or ANSIBLE_VAULT_PASSWORD_FILE environment variable.requirements.yml is installed via ansible-galaxy collection install -r requirements.yml before running the playbook.molecule docker driver and ensure Docker is available. Alternatively, use molecule vagrant with Vagrant installed.echo $ANSIBLE_VAULT_PASSWORD_FILEansible-playbook --vault-password-file $ANSIBLE_VAULT_PASSWORD_FILE playbook.ymlKey takeaways
Common mistakes to avoid
6 patternsHardcoding vault passwords in playbooks or inventory
Not using --limit or using empty string
Skipping --check mode before deployment
Not running Molecule tests before merge
Using process substitution for vault password in CI
Not cleaning up vault password file after use
Interview Questions on This Topic
How would you securely pass an Ansible vault password in a Jenkins Declarative Pipeline?
withCredentials step with a 'Secret text' credential. Write the password to a temporary file with restricted permissions, then pass it via --vault-password-file. Clean up the file in the post.always block. Example: withCredentials([string(credentialsId: 'vault-pass', variable: 'VAULT')]) { sh 'echo $VAULT > /tmp/vault-pass && chmod 600 /tmp/vault-pass && ansible-playbook --vault-password-file /tmp/vault-pass site.yml' }.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
That's Ansible. Mark it forged?
8 min read · try the examples if you haven't