Ansible + GitHub Actions: Build a Complete Infrastructure CI/CD Pipeline
Why Ansible Needs CI/CD
Most teams start their Ansible journey the same way: playbooks live in a Git repository (or worse, someone's home directory), and whoever needs a change runs ansible-playbook from their workstation. It works — until it doesn't.
The Problems with Laptop-Driven Automation
- Configuration drift - If playbooks only run when someone remembers to run them, servers drift away from the desired state. A pipeline that applies configuration on every merge keeps reality aligned with the repository.
- No review culture - Changes applied directly from a laptop skip peer review. Routing everything through pull requests means a second pair of eyes sees every infrastructure change before it lands.
- Environment inconsistency - "Works on my machine" applies to Ansible too. Different Ansible versions, collection versions, and Python versions on each engineer's laptop produce different results. A pipeline runs the same pinned toolchain every time.
- No audit trail - When production changes, you want to know exactly which commit, which playbook run, and which person triggered it. CI logs give you that for free.
- Repeatability - A deployment that only one person knows how to perform is a liability. Encoding it in a workflow file makes it a team capability.
The good news: Ansible is already declarative and idempotent, which makes it a perfect fit for CI/CD. You just need to wire it into a pipeline.
Repository Layout for a CI-Friendly Ansible Project
A clean, conventional layout makes every pipeline step simpler. Here's a structure that scales well:
ansible-infra/
├── .github/
│ └── workflows/
│ ├── ci.yml # Lint + syntax check on PRs
│ └── deploy.yml # Deploy on merge to main
├── ansible.cfg
├── requirements.yml # Collections and roles
├── requirements.txt # Python deps (ansible-core, ansible-lint)
├── inventories/
│ ├── staging/
│ │ ├── hosts.yml
│ │ └── group_vars/
│ │ └── all.yml
│ └── production/
│ ├── hosts.yml
│ └── group_vars/
│ └── all.yml
├── group_vars/
│ └── all/
│ ├── vars.yml
│ └── vault.yml # Encrypted with ansible-vault
├── roles/
│ ├── common/
│ ├── webserver/
│ └── database/
├── playbooks/
│ ├── site.yml
│ ├── webservers.yml
│ └── databases.yml
├── .ansible-lint
├── .yamllint
└── molecule/ # Role tests (optional but recommended)
Key points:
- Separate inventories per environment so the same playbook can target staging or production explicitly — the pipeline chooses the inventory, not the playbook.
- Pin everything:
requirements.txtpins the Ansible version,requirements.ymlpins collections and roles. CI installs from these files so runs are reproducible. - Vault files are committed encrypted — never plaintext secrets in Git.
Stage 1: Linting with ansible-lint and yamllint
The cheapest bugs to catch are the ones that never leave the pull request. Two tools do the heavy lifting:
yamllint- Catches YAML formatting issues: bad indentation, trailing spaces, inconsistent quoting.ansible-lint- Understands Ansible semantics: deprecated modules, missingnameon tasks, use ofcommandwhere a module exists, risky file permissions, and hundreds of other rules.
Add configuration files to the repository root so local runs and CI agree:
# .yamllint
---
extends: default
rules:
line-length:
max: 160
truthy:
allowed-values: ['true', 'false', 'yes', 'no']
ignore: |
.github/
molecule/
# .ansible-lint
---
profile: production
exclude_paths:
- .github/
- molecule/
skip_list: [] # Resist the temptation to grow this list
Run both locally before pushing (ansible-lint runs yamllint rules too, but running yamllint separately gives clearer output):
pip install ansible-lint yamllint
yamllint .
ansible-lint
Stage 2: The Pull Request Workflow
Here is a complete GitHub Actions workflow that runs on every pull request: lint, then a syntax check of every playbook against each inventory. Save it as .github/workflows/ci.yml:
---
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
ansible-galaxy install -r requirements.yml
- name: Run yamllint
run: yamllint .
- name: Run ansible-lint
run: ansible-lint
syntax-check:
name: Syntax check
runs-on: ubuntu-latest
needs: lint
strategy:
matrix:
inventory: [staging, production]
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
ansible-galaxy install -r requirements.yml
- name: Syntax check all playbooks
run: |
for playbook in playbooks/*.yml; do
ansible-playbook "$playbook" \
-i inventories/${{ matrix.inventory }}/hosts.yml \
--syntax-check
done
Enable branch protection on main and mark both jobs as required status checks. Now nothing merges without passing lint and syntax validation — your review culture just got teeth.
Tip
If your syntax check needs to resolve vault-encrypted variables, add --vault-password-file using the same secret technique shown below. --syntax-check doesn't connect to hosts, so it's safe to run against production inventories.
Secrets Handling in Pipelines
Playbooks need secrets — database passwords, API tokens, TLS keys. The rule is simple: secrets live encrypted in the repo (Ansible Vault or SOPS), and the decryption key lives in GitHub Secrets. Neither side is useful without the other.
Option A: Ansible Vault (Most Common)
Encrypt sensitive variable files with ansible-vault encrypt group_vars/all/vault.yml, then store the vault password as a GitHub Actions secret (Settings → Secrets and variables → Actions → New repository secret, e.g. ANSIBLE_VAULT_PASSWORD).
In the workflow, write the password to a file at runtime and point Ansible at it:
- name: Create vault password file
run: |
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault_pass
chmod 600 .vault_pass
- name: Run playbook
run: |
ansible-playbook playbooks/site.yml \
-i inventories/staging/hosts.yml \
--vault-password-file .vault_pass
- name: Remove vault password file
if: always()
run: rm -f .vault_pass
Option B: SOPS + age
SOPS encrypts values (not whole files), which makes diffs reviewable — you can see which key changed in a PR even though the value stays encrypted. Store the age private key in a GitHub secret and use the community.sops collection to load variables:
- name: Install sops
run: |
curl -sSLo /usr/local/bin/sops \
https://github.com/getsops/sops/releases/download/v3.9.0/sops-v3.9.0.linux.amd64
chmod +x /usr/local/bin/sops
- name: Configure age key
run: |
mkdir -p ~/.config/sops/age
echo "${{ secrets.SOPS_AGE_KEY }}" > ~/.config/sops/age/keys.txt
Never Do This
Never pass the vault password on the command line with --extra-vars or embed it in ansible.cfg. Command lines can leak into process listings and logs. Always use --vault-password-file with a file created from a secret at runtime.
Stage 3: Deploying on Merge to Main
Once a PR merges, a second workflow runs the playbook for real. The runner needs SSH access to your servers: generate a dedicated deploy key pair, put the public key on the target hosts (ideally for a dedicated deploy user with scoped sudo), and store the private key in a GitHub secret.
Save this as .github/workflows/deploy.yml:
---
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy-staging:
name: Deploy to staging
runs-on: ubuntu-latest
environment: staging
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
ansible-galaxy install -r requirements.yml
- name: Start SSH agent and load deploy key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_DEPLOY_KEY }}
- name: Add known hosts
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- name: Create vault password file
run: |
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault_pass
chmod 600 .vault_pass
- name: Run playbook against staging
run: |
ansible-playbook playbooks/site.yml \
-i inventories/staging/hosts.yml \
--vault-password-file .vault_pass \
--diff
- name: Clean up
if: always()
run: rm -f .vault_pass
deploy-production:
name: Deploy to production
runs-on: ubuntu-latest
needs: deploy-staging
environment: production
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
ansible-galaxy install -r requirements.yml
- name: Start SSH agent and load deploy key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_DEPLOY_KEY }}
- name: Add known hosts
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- name: Create vault password file
run: |
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault_pass
chmod 600 .vault_pass
- name: Run playbook against production
run: |
ansible-playbook playbooks/site.yml \
-i inventories/production/hosts.yml \
--vault-password-file .vault_pass \
--diff
- name: Clean up
if: always()
run: rm -f .vault_pass
A few details worth noting:
concurrencyprevents two deploys from running simultaneously if merges land back-to-back.--diffshows exactly what changed on each host, right in the Actions log — invaluable for auditing.- Known hosts come from a secret, populated with
ssh-keyscan -H your-serveroutput. This avoids disabling host key checking (more on that below).
Environment Gating: Staging First, Then Prod with Approval
The workflow above uses GitHub Environments (Settings → Environments). Create staging and production, then on production:
- Required reviewers - The
deploy-productionjob pauses until a designated person clicks "Approve" in the Actions UI. Combined withneeds: deploy-staging, production only runs after staging succeeded and a human signed off. - Environment-scoped secrets - Store separate
SSH_DEPLOY_KEYand vault passwords per environment, so the staging job physically cannot reach production hosts. - Deployment branch rules - Restrict the
productionenvironment to themainbranch only.
This gives you the classic promotion pipeline — code merges, staging converges automatically, a human approves, production converges — with a full audit trail of who approved what and when.
Testing Roles with Molecule
Lint and syntax checks prove your YAML is well-formed; Molecule proves your roles actually work. It spins up disposable containers, applies the role, and verifies the result — including an idempotence check (a second run must report zero changes).
Add a Molecule job to ci.yml:
molecule:
name: Molecule test
runs-on: ubuntu-latest
needs: lint
strategy:
fail-fast: false
matrix:
role: [common, webserver, database]
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install test dependencies
run: pip install ansible-core molecule molecule-plugins[docker]
- name: Run molecule
working-directory: roles/${{ matrix.role }}
run: molecule test
env:
PY_COLORS: "1"
ANSIBLE_FORCE_COLOR: "1"
GitHub-hosted Ubuntu runners ship with Docker preinstalled, so the Docker driver works out of the box. The matrix runs each role's test suite in parallel.
Dynamic Inventory in Pipelines
If your hosts live in AWS, Azure, or GCP, static inventory files go stale. Dynamic inventory plugins work perfectly in CI — you just need cloud credentials in secrets. Example with the AWS EC2 plugin:
# inventories/staging/aws_ec2.yml
---
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
filters:
tag:Environment: staging
keyed_groups:
- key: tags.Role
prefix: role
hostnames:
- private-ip-address
And in the workflow, authenticate with OIDC (no long-lived keys needed):
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-ansible-deploy
aws-region: us-east-1
- name: Verify inventory
run: ansible-inventory -i inventories/staging/aws_ec2.yml --graph
Running ansible-inventory --graph as a pipeline step is a cheap sanity check that the plugin resolved hosts before you attempt a deploy.
GitLab CI Equivalent
The same pipeline translates directly to GitLab CI. Here's a condensed .gitlab-ci.yml:
---
stages: [lint, test, deploy]
default:
image: python:3.12-slim
before_script:
- pip install -r requirements.txt
- ansible-galaxy install -r requirements.yml
lint:
stage: lint
script:
- yamllint .
- ansible-lint
syntax-check:
stage: test
script:
- ansible-playbook playbooks/site.yml -i inventories/staging/hosts.yml --syntax-check
deploy-staging:
stage: deploy
environment: staging
script:
- echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass && chmod 600 .vault_pass
- eval $(ssh-agent -s)
- echo "$SSH_DEPLOY_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- ansible-playbook playbooks/site.yml -i inventories/staging/hosts.yml --vault-password-file .vault_pass --diff
after_script:
- rm -f .vault_pass
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy-production:
stage: deploy
environment: production
needs: [deploy-staging]
when: manual # GitLab's approval gate
script:
- echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass && chmod 600 .vault_pass
- eval $(ssh-agent -s)
- echo "$SSH_DEPLOY_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- ansible-playbook playbooks/site.yml -i inventories/production/hosts.yml --vault-password-file .vault_pass --diff
after_script:
- rm -f .vault_pass
rules:
- if: $CI_COMMIT_BRANCH == "main"
Mark ANSIBLE_VAULT_PASSWORD and SSH_DEPLOY_KEY as masked and protected CI/CD variables in GitLab so they never appear in job logs and are only available on protected branches.
Common Pitfalls
1. Host Key Checking Failures
Fresh CI runners have an empty known_hosts, so the first SSH connection fails with "Host key verification failed". The lazy fix is disabling checking:
# Works, but weakens security — you lose MITM protection
export ANSIBLE_HOST_KEY_CHECKING=False
The better fix: capture host keys once with ssh-keyscan -H host1 host2, store the output in a secret (or commit it to the repo — public keys aren't secret), and write it to ~/.ssh/known_hosts in the workflow, as shown in the deploy examples above.
2. SSH Agent and Key Permissions
Writing a private key to disk with default permissions makes SSH refuse it ("UNPROTECTED PRIVATE KEY FILE"). Use an SSH agent action (like webfactory/ssh-agent) which keeps the key in memory, or chmod 600 the file immediately. Also watch for Windows-style line endings in pasted secrets — tr -d '\r' saves hours of debugging.
3. Vault Password Exposure in Logs
GitHub masks registered secrets in logs, but masking fails if you transform the value (base64, echo with set -x enabled, etc.). Never echo a secret for debugging, never run vault commands under set -x, and never pass secrets as command-line arguments to ansible-playbook — Ansible's own verbose output (-vvv) can also print variable values, so avoid high verbosity in production deploy jobs.
4. Forgetting ansible.cfg Is Ignored in World-Writable Dirs
Ansible refuses to load ansible.cfg from a world-writable directory. Some CI checkouts trigger this silently, and suddenly your roles_path and settings vanish. Set ANSIBLE_CONFIG=$PWD/ansible.cfg explicitly in the workflow environment to be safe.
5. Unpinned Tool Versions
A new ansible-lint release with stricter rules can turn every open PR red overnight. Pin versions in requirements.txt and upgrade deliberately in their own PR.
6. Check Mode as a PR Gate — Use with Care
Running ansible-playbook --check --diff on PRs to preview changes is tempting, but it requires connecting to real hosts from an unmerged branch — a security risk if your repo accepts external PRs, since workflow changes in the PR could exfiltrate secrets. If you do this, restrict it to internal branches and use environment protections.
Conclusion
With a few workflow files, your Ansible repository transforms from a folder of scripts into a real deployment platform:
- Every change is peer-reviewed and gated by
yamllint,ansible-lint, and syntax checks - Roles are functionally verified in containers with Molecule
- Merges to
mainautomatically converge staging - Production deploys require a recorded human approval
- Secrets stay encrypted at rest and masked in logs
Start small: add the lint workflow to your existing repo today — it's pure upside with zero access to servers. Then layer in syntax checks, then automated staging deploys, and finally gated production. Within a few iterations you'll wonder how you ever ran playbooks from a terminal on Friday afternoons.
Pro Tip
Add ansible-playbook ... --check --diff as a manual workflow_dispatch job against staging. It gives you a one-click "what would change?" preview from the Actions tab — perfect for validating a risky change before merging.