CI/CD

Ansible + GitHub Actions: Build a Complete Infrastructure CI/CD Pipeline

Teach me Ansible | 2026-08-17 | 20 min read

Stop running playbooks from your laptop. Learn how to build a complete infrastructure CI/CD pipeline with Ansible and GitHub Actions — linting and syntax checks on every pull request, automated deployments on merge, environment gating with approvals, and secure secrets handling with Ansible Vault.

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.txt pins the Ansible version, requirements.yml pins 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, missing name on tasks, use of command where 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:

  • concurrency prevents two deploys from running simultaneously if merges land back-to-back.
  • --diff shows 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-server output. 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:

  1. Required reviewers - The deploy-production job pauses until a designated person clicks "Approve" in the Actions UI. Combined with needs: deploy-staging, production only runs after staging succeeded and a human signed off.
  2. Environment-scoped secrets - Store separate SSH_DEPLOY_KEY and vault passwords per environment, so the staging job physically cannot reach production hosts.
  3. Deployment branch rules - Restrict the production environment to the main branch 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 main automatically 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.