Ansible Playbooks skill for Codex
Second run reports changed=0, every time. Codex writes Ansible roles that use modules with explicit state instead of shell commands, marks read-only commands as unchanged, validates configs before handlers reload services, and keeps secrets in vaulted vault_ variables with no_log. Rollouts go canary first with serial batches and health checks, and every change is proven by a second staging run that reports zero changes.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ansible-playbooks Installs to ~/.agents/skills/ansible-playbooks/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ansible-playbooks The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ansible-playbooks Installs to .agents/skills/ansible-playbooks/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ansible-playbooks.zipunzip -o ansible-playbooks.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ansible-playbooks PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ansible-playbooks, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes idempotent Ansible automation - playbooks and roles with FQCN modules, YAML inventories with group_vars and host_vars, handlers, check and diff mode, Ansible Vault for secrets, rolling updates with serial and max_fail_percentage, collection requirements, ansible-lint and Molecule tests. Use when automating server configuration, converting shell scripts to Ansible, structuring roles and inventories, encrypting secrets with ansible-vault, fixing tasks that report changed on every run, or reviewing a playbook before it runs against production hosts.
Use it when
- Servers or VMs need repeatable configuration
- Shell scripts that provision hosts should become reviewable automation
- Secrets used by playbooks need encrypting with Ansible Vault
- A change must roll across a fleet with a canary and health checks
Use something else when
- You are hardening SSH, firewalls and users on a Linux server as a policy exercise: use linux-server-hardening
- The infrastructure itself (VMs, networks) still needs creating: use terraform-modules
How to check the result
ansible-lint and --syntax-check pass, and two consecutive runs against staging show the second run with changed=0 on every host.
The mistake it prevents
Wrapping commands in shell without creates or changed_when, so every run reports changes and check mode cannot predict anything.
A playbook is right when the second run reports changed=0, --check --diff predicts
exactly what a real run does, secrets are vaulted and never printed, and a rollout across
many hosts stops before a bad change reaches all of them.
Inspect first
- Read
AGENTS.md,ansible.cfg, the inventory (inventory/,hosts.yml) andrequirements.ymlfor collections and roles. - Check versions:
ansible --version(ansible-core 2.18+ expected; 2.19 tightened templating and conditionals, so older playbooks may warn or fail) andansible-galaxy collection listfor collection versions. - Map the inventory:
ansible-inventory -i inventory/ --graphandansible-inventory -i inventory/ --host web1to see merged variables. - Find existing roles that already manage the thing you are about to touch; two roles writing the same file fight each other on every run.
Running against hosts needs SSH reachability and credentials, and installing
collections needs network (Galaxy or a private hub). In a sandbox, write the code and
run --syntax-check and ansible-lint locally; leave host runs to the user.
Layout
ansible/
├── ansible.cfg
├── requirements.yml
├── inventory/
│ ├── production/
│ │ ├── hosts.yml
│ │ ├── group_vars/
│ │ │ ├── all.yml
│ │ │ └── web/
│ │ │ ├── vars.yml
│ │ │ └── vault.yml # ansible-vault encrypted
│ │ └── host_vars/
│ └── staging/
├── playbooks/
│ └── web.yml
└── roles/
└── nginx/
├── defaults/main.yml # overridable defaults (lowest precedence)
├── vars/main.yml # internal constants (high precedence; rarely needed)
├── tasks/main.yml
├── handlers/main.yml
├── templates/nginx.conf.j2
├── meta/main.yml
├── meta/argument_specs.yml
└── molecule/default/
Separate inventories per environment so -i inventory/staging cannot touch production.
# inventory/production/hosts.yml
all:
children:
web:
hosts:
web1.example.com:
web2.example.com:
db:
hosts:
db1.example.com:
A good role task file
# roles/nginx/tasks/main.yml
- name: Install nginx
ansible.builtin.apt:
name: "nginx={{ nginx_version }}"
state: present
update_cache: true
cache_valid_time: 3600
become: true
- name: Render site config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
become: true
notify: Reload nginx
- name: Ensure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
become: true
# roles/nginx/handlers/main.yml
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
become: true
# roles/nginx/meta/argument_specs.yml
argument_specs:
main:
options:
nginx_version:
type: str
required: true
nginx_worker_connections:
type: int
default: 1024
Argument specs validate role inputs before any task runs.
Idempotency rules
- Use a module with
state:before reaching forcommand/shell. Modules know whether they changed something; shell commands do not. - When
command/shellis unavoidable, make it honest:
- name: Initialise the database cluster
ansible.builtin.command: /usr/lib/postgresql/17/bin/initdb -D /var/lib/postgresql/17/main
args:
creates: /var/lib/postgresql/17/main/PG_VERSION
become: true
become_user: postgres
- name: Read current schema version
ansible.builtin.command: /opt/app/bin/migrate version
register: schema_version
changed_when: false
check_mode: false
lineinfilefor one line in a file you do not own;templatefor files you do own. Manylineinfiletasks against one file means it should be a template.- Quote file modes (
"0644"); unquoted0644is parsed as an octal int in some contexts and decimal in others. - Handlers run once at the end of the play even if notified many times. Use
ansible.builtin.meta: flush_handlerswhen a later task needs the restart to have happened. - Use FQCNs (
ansible.builtin.copy,community.general.ufw) everywhere. - Prefer
ansible_facts['distribution']over injected top-level fact variables likeansible_distribution; recent ansible-core releases deprecate the injected form. ignore_errors: truehides real failures. Usefailed_whenwith an explicit condition.
Secrets with Vault
ansible-vault create inventory/production/group_vars/web/vault.yml --vault-id prod@prompt
ansible-vault encrypt_string --vault-id prod@prompt 's3cr3t' --name 'vault_db_password'
ansible-playbook playbooks/web.yml -i inventory/production --vault-id prod@~/.ansible/prod.vault-pass
- Convention: vaulted variables are prefixed
vault_invault.yml, referenced from plainvars.yml(db_password: "{{ vault_db_password }}") sogrepstill finds every variable. - Set
no_log: trueon tasks that handle secrets;-voutput and failure messages otherwise print them. - Vault password files live outside the repo with
0600permissions; never commit them. In CI, read them from the CI secret store.
Safe rollouts
# playbooks/web.yml
- name: Configure web tier
hosts: web
serial: [1, "25%"] # canary one host, then 25 percent batches
max_fail_percentage: 0
any_errors_fatal: true
pre_tasks:
- name: Drain from load balancer
ansible.builtin.uri:
url: "https://lb.internal/api/pools/web/members/{{ inventory_hostname }}/drain"
method: POST
delegate_to: localhost
roles:
- role: nginx
post_tasks:
- name: Health check
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}/healthz"
status_code: 200
retries: 10
delay: 3
register: health
until: health.status == 200
Running any playbook against production hosts requires the user’s explicit
confirmation after they have seen a --check --diff run against the same inventory and limit.
Workflow
- [ ] 1. Read inventory, cfg, requirements; graph the target hosts
- [ ] 2. Write or change roles; argument specs for new inputs
- [ ] 3. ansible-playbook --syntax-check and ansible-lint
- [ ] 4. Molecule test for the role (if Docker or Podman is available)
- [ ] 5. --check --diff against staging, then a real staging run
- [ ] 6. Second staging run: changed=0
- [ ] 7. With confirmation: production with --limit canary first, then the rest
Verification
ansible-galaxy collection install -r requirements.yml # network
ansible-playbook playbooks/web.yml -i inventory/staging --syntax-check
ansible-lint playbooks/ roles/
molecule test -s default # in roles/nginx
ansible-playbook playbooks/web.yml -i inventory/staging --check --diff
ansible-playbook playbooks/web.yml -i inventory/staging # run 1
ansible-playbook playbooks/web.yml -i inventory/staging # run 2: expect changed=0
--check cannot predict tasks that depend on earlier tasks’ real results (a package that
is not installed yet); those show as skipped or failed in check mode. Note them rather than
calling the check clean.
Output
**Scope:** role nginx (new), playbook web.yml; inventory staging + production (web group, 6 hosts)
**Lint/tests:** ansible-lint 0 violations, molecule default scenario passes (Debian 12, Ubuntu 24.04)
**Staging:** check/diff reviewed; run 1 changed=7, run 2 changed=0 on all hosts
**Secrets:** vault_tls_key in group_vars/web/vault.yml (vault-id prod), no_log on 2 tasks
**Production:** not run; proposed `--limit web1.example.com` canary, then serial 25% What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Ansible Playbooks"
short_description: "Second run reports changed=0, every time"
default_prompt: "$ansible-playbooks Convert this server setup bash script into an Ansible role" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.