Writing Ansible Playbooks With LLMs in October 2026: Verify Every Module, Make Tasks Idempotent, and Dry-Run Before You Touch the Fleet
Ansible is one of the places where an LLM looks most helpful. Playbooks are YAML, the module names read like plain English, and a model can turn "install nginx, drop in a config, and restart it" into thirty tidy lines in a few seconds. The problem is that Ansible runs with real privileges against real machines, often many at once. A playbook that looks right but uses a made-up module option, shells out where a module exists, or restarts a service on every run can do quiet damage across a whole fleet.
This guide walks through a practical workflow for generating Ansible playbooks and roles with an LLM in October 2026: how to prompt so you get something checkable, how to verify every module and option, how to make tasks idempotent, how to keep secrets out of logs, and how to test before you touch production hosts.
Why LLM-written playbooks go wrong
Most problems fall into a handful of patterns. Knowing them makes review much faster.
- Invented or outdated options. Models mix up parameters between similar modules, use options that were renamed, or reach for modules that have been deprecated or moved to a collection.
- Short module names. Generated code often writes
apt:orcopy:instead of the fully qualified collection name such asansible.builtin.apt. It usually still works, but it is ambiguous and linters flag it. - Shell where a module exists. A task like
shell: useradd deployworks the first time and fails or reports a change every time after. - Non-idempotent tasks.
commandandshelltasks report "changed" on every run unless you tell Ansible how to judge that, which breaks handlers and makes check mode useless. - Secrets in plain text. Passwords and API tokens end up hard-coded in vars, or printed in task output because nobody set
no_log. - Blast radius ignored. A playbook targets
hosts: alland restarts a service everywhere at once, with no rolling batch and no health check.
Step 1: Give the model the facts it cannot guess
A vague prompt gets a generic playbook. Tell the model exactly what environment it is writing for. A useful prompt includes:
- Your
ansible-coreversion (runansible --version) and which collections you have installed (ansible-galaxy collection list). - Target operating systems and versions, for example Ubuntu 24.04 and Rocky Linux 9.
- Your conventions: fully qualified module names, roles layout, where variables live, and whether you use
becomeper task or per play. - Explicit rules: "use modules instead of shell or command whenever a module exists," "every task must be idempotent," "mark any task that handles secrets with
no_log: true."
For example:
Write an Ansible role for ansible-core 2.17 targeting Ubuntu 24.04.
It should install nginx, deploy /etc/nginx/sites-available/app.conf
from a Jinja2 template, enable the site, and reload nginx only when
the config changes. Use fully qualified module names, no shell or
command tasks unless no module exists, validate the nginx config
before it is put in place, and keep all tunable values in
defaults/main.yml.
Asking for a role rather than one long playbook also helps. Roles force a structure (tasks, handlers, templates, defaults) that is easier to review and reuse.
Step 2: Verify every module and option against real docs
Do not trust that a module or parameter exists just because it looks plausible. Ansible ships its documentation locally, so checking is quick:
ansible-doc ansible.builtin.template
ansible-doc -l | grep -i firewall
ansible-doc community.general.ufw
For each task the model wrote, confirm three things: the module exists in a collection you have installed, every option it uses is listed in ansible-doc, and the values match the documented types and choices. If a module comes from a collection you do not have, add that collection to a requirements.yml with a pinned version rather than installing it ad hoc.
Watch especially for older patterns the model learned from years of blog posts. A common one is adding third-party apt repositories with the apt-key approach, which Debian and Ubuntu have deprecated. Current guidance is to store the repository key in a dedicated keyring file and reference it with signed-by; recent ansible-core releases include ansible.builtin.deb822_repository for exactly this.
Step 3: Make every task idempotent
Idempotency means running the playbook twice in a row produces no changes the second time. It is the property that makes Ansible safe to rerun, and it is the property LLM output most often breaks.
Prefer modules over shell
Replace commands with the module that manages the same resource:
# Instead of: shell: useradd -m deploy
- name: Ensure deploy user exists
ansible.builtin.user:
name: deploy
shell: /bin/bash
create_home: true
# Instead of: shell: systemctl enable --now nginx
- name: Ensure nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
When you must use command, describe the change
Some jobs genuinely need command. In that case tell Ansible when it is safe to skip and how to tell whether anything changed:
- name: Initialize the app database once
ansible.builtin.command:
cmd: /opt/app/bin/init-db
creates: /var/lib/app/.db-initialized
- name: Check current schema version
ansible.builtin.command: /opt/app/bin/schema-version
register: schema_version
changed_when: false
check_mode: false
creates skips the task if the file already exists. changed_when: false marks a read-only query as never changing anything, and check_mode: false lets that read run even during a dry run so later tasks have real data.
Use handlers for restarts
Generated playbooks often restart a service as a normal task, which means it restarts on every run. Notify a handler instead so the restart only happens when a config file actually changed:
# tasks/main.yml
- name: Deploy nginx site config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/nginx/sites-available/app.conf
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
notify: Reload nginx
# handlers/main.yml
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
One caution on that validate line: nginx -t -c %s tests the rendered file as if it were the main config, which works for a full nginx.conf but can fail for a site snippet that only contains a server block. For snippets, a common approach is to deploy the file, then run nginx -t against the whole configuration before the reload, and fail the play if it does not pass. Either way, the point is the same: never reload a service on a config you have not tested.
Step 4: Keep secrets out of the playbook and out of the logs
Ask the model to reference secrets as variables, never as literal values. Then store those variables encrypted:
ansible-vault create group_vars/prod/vault.yml
ansible-vault edit group_vars/prod/vault.yml
A common convention is to keep encrypted values in a vault.yml with names like vault_db_password, then map them to readable names in an unencrypted vars.yml (db_password: "{{ vault_db_password }}"). That way you can still search your repo for where a variable is used without decrypting anything.
Encryption at rest is only half the job. Any task that receives a secret can echo it in verbose output or in an error message. Add no_log: true to those tasks:
- name: Set database user password
community.postgresql.postgresql_user:
name: app
password: "{{ db_password }}"
no_log: true
Before committing, search the generated files for anything that looks like a key, token, or password literal. LLMs sometimes fill in "example" credentials that look real enough to get copied into production.
Step 5: Lint and syntax-check before running anything
Static checks catch a large share of LLM mistakes in seconds:
ansible-playbook site.yml --syntax-check
ansible-lint site.yml
--syntax-check confirms the YAML and play structure parse. ansible-lint goes further: it flags short module names, command tasks that should be modules, missing changed_when, risky file permissions, and other common issues. Run it in CI on every pull request so generated code gets the same scrutiny as hand-written code. If you disable a rule, do it in the config file with a comment explaining why, not by silencing it per task without explanation.
Step 6: Dry-run against real hosts with check and diff
Once the playbook passes lint, see what it would do without doing it:
ansible-playbook -i inventory/staging site.yml --check --diff --limit web01
--check asks modules to report what they would change, and --diff shows the actual line-by-line changes to templated and copied files. --limit keeps the run to one host. Read the diff carefully. This is where you notice that the model's template drops a directive your existing config relied on, or that a lineinfile regex matches the wrong line.
Keep in mind that check mode is a prediction, not a guarantee. command and shell tasks are skipped in check mode unless configured otherwise, and tasks that depend on something an earlier task would have created may report errors that would not happen in a real run. Treat a clean check run as necessary but not sufficient.
Step 7: Test roles in throwaway environments
For roles you will reuse, Molecule lets you run them against disposable containers or VMs, then rerun them to verify idempotency automatically. A basic loop looks like:
molecule create
molecule converge
molecule idempotence
molecule verify
molecule destroy
The idempotence step runs the role a second time and fails if anything reports a change. That single check catches most of the "restarts every time" and "shell task always changed" problems described above. Even without Molecule, you can get the same signal by running the playbook twice against a staging host and confirming the second run reports changed=0.
Step 8: Roll out in batches with a way to stop
When the playbook reaches production, limit how many hosts change at once. Generated plays rarely include this unless you ask:
- name: Update web tier
hosts: web
serial: "20%"
max_fail_percentage: 0
become: true
roles:
- nginx_app
post_tasks:
- name: Confirm app responds
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}/healthz"
status_code: 200
retries: 5
delay: 3
register: health
until: health.status == 200
serial processes hosts in batches, max_fail_percentage: 0 stops the rollout if any host in a batch fails, and the health check in post_tasks makes "failed" mean "the app is broken," not just "a task errored." If your hosts sit behind a load balancer, add tasks to drain each host before changing it and re-enable it afterward.
A quick review checklist
- Every module is fully qualified and confirmed with
ansible-doc. - Every option exists for the installed version, and collections are pinned in
requirements.yml. - No
shellorcommandtask where a module exists; the rest havecreates,removes, orchanged_when. - Service restarts and reloads happen through handlers only.
- Config files are validated before services pick them up.
- Secrets live in Ansible Vault and tasks that touch them use
no_log: true. --syntax-checkandansible-lintpass in CI.--check --diffon one staging host shows only the changes you expect.- A second run reports
changed=0. - Production plays use
serial, a failure threshold, and a health check.
The bottom line
An LLM is a fast way to get a first draft of a playbook or role, especially for boilerplate like directory layouts, templates, and handler wiring. What it cannot do is know your ansible-core version, your installed collections, or how your servers behave when a service restarts mid-request. Treat the generated YAML as a proposal: verify the modules, enforce idempotency, lock down secrets, lint it, dry-run it, prove a second run changes nothing, and roll it out in small batches. That workflow keeps the speed of generation without handing an unreviewed script root access to your whole fleet.
Comments
Post a Comment