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.

August Sound Collective's broadcast output rig (2007 04 04 19.06.31 by Lee Azzarello)
Image: Lee from Brooklyn, NY, USA via Wikimedia Commons (CC BY-SA 2.0)
Artificial Neural Network with Chip
Image: mikemacmarketing / photo on flickr via Wikimedia Commons (CC BY 2.0)

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: or copy: instead of the fully qualified collection name such as ansible.builtin.apt. It usually still works, but it is ambiguous and linters flag it.
  • Shell where a module exists. A task like shell: useradd deploy works the first time and fails or reports a change every time after.
  • Non-idempotent tasks. command and shell tasks 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: all and 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-core version (run ansible --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 become per 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

  1. Every module is fully qualified and confirmed with ansible-doc.
  2. Every option exists for the installed version, and collections are pinned in requirements.yml.
  3. No shell or command task where a module exists; the rest have creates, removes, or changed_when.
  4. Service restarts and reloads happen through handlers only.
  5. Config files are validated before services pick them up.
  6. Secrets live in Ansible Vault and tasks that touch them use no_log: true.
  7. --syntax-check and ansible-lint pass in CI.
  8. --check --diff on one staging host shows only the changes you expect.
  9. A second run reports changed=0.
  10. 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

Popular posts from this blog

Grok Bot - a step closer to AGI

Structured Outputs and Constrained Decoding for Reliable LLM APIs in Production (September 2026)

Tencent Hy4 Preview: Open 770B MoE Built for Real Work