Day 4 · Ansible in CI/CD — Automating Configuration with GitHub Actions¶
Running Terraform from a pipeline has an equivalent for configuration: Ansible. Configuration drifts just like infrastructure — someone SSHes in, tweaks nginx, forgets. Running your playbooks from CI means every config change is reviewed, linted, dry-run, and applied by a machine — never by hand on a box nobody else knows about. The example is kept tiny: one playbook against one server, so the focus stays on the automation pattern.
Where this fits
This applies the CI/CD pattern to Ansible, completing the toolkit — lint/test/build, image publish, IaC, and config management, all in GitHub Actions. Deploy a Full-Stack AWS Project combines them into a full end-to-end deploy.
Learning Objectives¶
- Explain why configuration management belongs in CI, and the gates that make it safe
- Use
ansible-lint,--syntax-check, and--check(dry run) as pipeline stages - Supply an SSH key and Vault password to a workflow from GitHub secrets
- Apply a playbook on merge, and rely on idempotency as a safety net
- Lab: a minimal playbook run by GitHub Actions against an EC2 host
Prerequisites¶
- Week 3 complete — Ansible basics, inventory, roles (Days 15–16)
- An EC2 instance you can SSH into (from Day 2/3, or launch a fresh
t3.micro) with its private key - A new, dedicated GitHub repo for this lab (e.g.
ansible-ci) — this day doesn't reuse the sample-app repo
Theory · ~30 min¶
1. Why run Ansible in CI¶
The same argument as Terraform, applied to configuration:
Manual ansible-playbook |
Pipeline |
|---|---|
| Run from someone's laptop, ad-hoc | Clean runner, pinned collections, reproducible |
| No review of what changed | Every change is a reviewed PR |
| Vault password / SSH key on laptops | Injected from encrypted secrets at run time |
| "Did anyone run it on all hosts?" | The pipeline runs it, every time, on the full inventory |
2. The Ansible pipeline — gates before you touch a server¶
Ansible has its own cheap-to-expensive gates, mirroring lint→test→apply:
PR ──▶ ansible-lint ──▶ --syntax-check ──▶ --check (dry run) ──▶ review
│
merge to main ──────────────────────────────────────────▶ ansible-playbook (apply)
| Stage | Catches |
|---|---|
ansible-lint |
Bad practices, deprecated modules, style |
--syntax-check |
YAML/playbook structure errors — before connecting anywhere |
--check (dry run) |
Would-be changes, with --diff to show them — the "plan" of Ansible |
| apply | The real change, on merge |
--check is Ansible's answer to terraform plan: it reports what would change without changing it — so a PR can show the intended effect.
3. Secrets: SSH key + Vault password¶
A CI run needs two secrets to configure a real host:
- The SSH private key to reach the host — stored as a repo secret, written to a file at run time.
- The Ansible Vault password to decrypt any vaulted vars — stored as a secret, passed via
--vault-password-file.
Neither ever lives in the repo. The runner is ephemeral, so they exist only for the seconds the job runs.
Reaching the host from a hosted runner
GitHub-hosted runners have dynamic IPs, so the target's security group must allow SSH from anywhere (0.0.0.0/0 on 22) with key-only auth — acceptable for a lab, not ideal for production. Real setups use a self-hosted runner inside the VPC, a bastion, or SSM. We note it now and use it properly on Day 5.
4. Idempotency is your CI safety net¶
Because playbooks are idempotent (Week 3), re-running one is safe — the second apply changes nothing. That's what makes automated config safe to run on every merge: applying an unchanged playbook is a no-op, so the pipeline can run freely without fear of "doing it twice."
Lab · ~45 min¶
Run a small playbook against an EC2 host from GitHub Actions: lint + syntax + dry-run on PRs, apply on merge.
Runnable version in the repo
The complete project is at examples/cicd/ansible-ci — copy it into a fresh ansible-ci repo, or build it from the snippets below.
1. The playbook¶
This lab lives in its own repo — create an empty ansible-ci and clone it (don't reuse the sample-app repo). In it, make an ansible/ folder. ansible/playbook.yml — install nginx and drop a page (deliberately simple):
- name: Configure the web server
hosts: web
become: true
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Deploy the landing page
ansible.builtin.copy:
content: "Configured by GitHub Actions + Ansible\n"
dest: /var/www/html/index.html
mode: "0644"
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
ansible/inventory.ini — your one host (public IP of the EC2):
2. Store the secrets¶
Repo → Settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
SSH_PRIVATE_KEY |
full contents of your .pem |
(No Vault secret needed for this simple playbook — the assignment adds one.)
3. The workflow¶
.github/workflows/ansible.yml:
name: Ansible
on:
pull_request:
paths: ["ansible/**"]
push:
branches: [main]
paths: ["ansible/**"]
jobs:
ansible:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ansible
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install Ansible + lint
run: pip install ansible ansible-lint
- name: Lint
run: ansible-lint playbook.yml
- name: Syntax check
run: ansible-playbook --syntax-check -i inventory.ini playbook.yml
- name: Set up SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Dry run (on PRs)
if: github.event_name == 'pull_request'
run: ansible-playbook -i inventory.ini playbook.yml --check --diff
env:
ANSIBLE_HOST_KEY_CHECKING: "False"
- name: Apply (on merge to main)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ansible-playbook -i inventory.ini playbook.yml
env:
ANSIBLE_HOST_KEY_CHECKING: "False"
4. Run the cycle¶
- Open a PR. Watch
ansible-lint→--syntax-check→--check --diffrun; the dry-run's--diffshows the file it would write. - Merge. The apply step runs the playbook for real. Visit
http://<ec2-public-ip>— your page is live. - Change the page text on a branch, open a PR — the dry run shows the diff. Merge to apply it.
5. See idempotency in CI¶
Re-run the apply (re-run the workflow, or push an empty change). The playbook reports ok, not changed — nothing to do. That's why running config on every merge is safe.
What you just built
A reviewed, linted, dry-run-then-applied Ansible pipeline. You've now automated all four building blocks — test, image, Terraform, Ansible. Day 5 assembles them into the end-to-end project.
Advanced Topics¶
- Molecule — test roles in throwaway containers as a CI gate → Molecule
- Self-hosted runners in the VPC — reach private hosts without opening SSH to the world → Self-hosted runners
--check --difflimits — some modules can't predict changes; know where dry-run is blind → Check mode- Dynamic inventory in CI — query AWS by tag instead of hard-coding IPs →
amazon.aws.aws_ec2
Assignment¶
Write a playbook — run by the Ansible pipeline — that uses a community Galaxy role to install Docker, then runs an nginx container serving a custom page. Same task as by hand; the one change is the pipeline runs it (lint + syntax + --check on PRs, apply on merge).
Steps:
- Install
geerlingguy.docker— declare it in arequirements.yml; the workflow installs it withansible-galaxy install -r requirements.ymlbefore running the play. - Write a playbook targeting the
webgroup that: - applies the
geerlingguy.dockerrole to install Docker, - deploys
index.htmlfrom a Jinja2 template (index.html.j2) that renders the message plus the host's uptime at deploy time — e.g.Hello World from Ansible!!!. Last Uptime: up 13 hours, 50 minutes.— where the uptime is read from the target host during the run (not hard-coded), - runs an nginx container, mapping container port 80 to host port 8080, with your page mounted as the site root.
- Point
inventory.iniat an EC2 you can reach (public IP; its security group allows SSH from the runner and 8080 from you), and add its key as theSSH_PRIVATE_KEYsecret. The pipeline runs lint → syntax →--check --diffon the PR, then applies on merge. Verify:curl http://<ec2-public-ip>:8080returns yourHello World from Ansible!!!. Last Uptime: …page with the real uptime. Re-run the workflow and confirm the uptime reflects the host each deploy.
Hint — the uptime: capture it from the target host with a task, then reference the registered variable in index.html.j2:
- name: Read host uptime
ansible.builtin.command: uptime -p # → "up 13 hours, 50 minutes"
register: host_uptime
changed_when: false
Hint — the container: once Docker is installed, run it with the community.docker.docker_container module:
- name: Run nginx in a container
community.docker.docker_container:
name: hello-nginx
image: nginx:latest
state: started
ports:
- "8080:80"
volumes:
- "/opt/site:/usr/share/nginx/html:ro"
Submit: your requirements.yml, playbook.yml, index.html.j2, the workflow, the PR --check --diff, and the curl output.
Clean up
Stop or terminate the EC2 when you're done.
Further Reading¶
Watch
- 📺 Ansible in CI/CD pipelines — linting, check mode, and automated runs
Reference