GitHub Copilot for Ansible

GitHub Copilot for DevOps & InfrastructureAcademy lesson 45Cluster 4 · Lesson 7 of 13Intermediate13 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for AnsibleGitHub Copilot for DevOps & Infrastructure7Intermediate/github-copilot/devops/ansible/

Ansible has a property no other technology in this cluster shares: its correctness criterion is not “does it work” but “does it do nothing the second time”.

A playbook that installs a package, writes a file and restarts a service works perfectly on the first run. Whether it is correct is a question about the second run, and the answer decides whether you can safely run it against two hundred hosts every hour.

Generated Ansible is where that distinction gets lost most often, and the reason is structural: a shell: task is the shortest way to express almost anything, and a shell: task is never idempotent unless you make it so.

Here is the measurement. A plausible first-draft playbook against ansible-lint at its default profile:

Failed: 19 failure(s), 0 warning(s) in 1 files

And the finished role in the example below:

Passed: 0 failure(s), 0 warning(s) in 8 files
Last profile that met the validation criteria was 'production'

Nineteen to zero, at the strictest profile the tool offers. That is a good result. It is also the setup for the finding that matters more.

What Copilot is good at, and the one thing it defaults to

Reliably good: role scaffolding, Jinja2 templates, group_vars structure, loops and conditionals, converting a documented manual procedure into tasks, and explaining an inherited role. The module catalogue is enormous and well documented, which is the profile of a thing worth delegating — nobody remembers whether it is ansible.builtin.systemd_service or ansible.builtin.service.

The default it reaches for is shell:. This is not carelessness; it is the shortest correct-looking way to express any operation, and public playbooks are full of it. But every shell: task reports changed on every run, which means the playbook can never be quiet, which means nobody can distinguish a real change from noise — and once that is true, running it regularly stops being safe.

Copilot promptThe clause that fixes most generated AnsibleCopilot Chat

Use a dedicated Ansible module rather than shell or command wherever one exists.

If command is genuinely necessary, add changed_when and creates so the task is idempotent, and explain in a comment why no module fits.

The draft, and what the linter saw

- hosts: all
  become: yes
  tasks:
  - name: install nginx
    shell: apt-get install -y nginx

  - name: copy config
    copy: src=site.conf dest=/etc/nginx/conf.d/site.conf

  - name: restart nginx
    shell: systemctl restart nginx

  - name: set password
    lineinfile:
      path: /etc/app/config
      line: "db_password=hunter2"

The nineteen findings included fqcn[action-core] four times (unqualified module names), name[casing] five times, command-instead-of-module twice, command-instead-of-shell twice, no-changed-when twice, risky-file-permissions once, and two YAML formatting rules.

Every one is correct and worth fixing. Now the part that matters:

Beyond the linter’s list, four structural problems:

hosts: all. The playbook targets every host in whatever inventory it is given. Combined with a forgotten -i flag pointing at the default inventory, this is how a development playbook reaches production.

become: yes at play level. Every task runs as root, including the ones that do not need to.

shell: apt-get install is not idempotent, is not portable off Debian, and skips the module’s handling of the package cache and of already-installed packages.

A restart with no handler. The service restarts on every run whether or not the configuration changed.

Practical project: an idempotent role

Practical example

A web server role that passes ansible-lint at the production profile

A role that converges rather than repeats: modules instead of shell, a handler for the restart, a validated template, and an inventory assertion before anything runs.

Status
Tested implementation
Runtime
ansible-core 2.21.3, ansible-lint 26.8.0
Command
ansible-playbook --syntax-check -i inventory.ini site.yml && ansible-lint
Result
Syntax check parsed with no errors. ansible-lint: Passed: 0 failure(s), 0 warning(s) in 8 files, meeting the 'production' profile. The draft playbook produced 19 failures.
Run on
August 21, 2026

Files

examples/cluster-4/ansible

copilot-ansible-demo/ ├── ansible.cfg host key checking on, become off by default ├── inventory.ini one host: localhost, local connection ├── group_vars/web.yml ├── site.yml play with a pre_task inventory assertion ├── roles/web/ │ ├── defaults/main.yml the role’s documented interface │ ├── tasks/main.yml package, directory, template, service │ ├── handlers/main.yml reload, notified by the template task │ ├── templates/site.conf.j2 │ └── meta/main.yml ├── draft/site-draft.yml the first suggestion, kept for comparison └── .github/ └── instructions/ └── ansible.instructions.md

The prompt

Copilot promptGenerate the roleCopilot Chat

Create an idempotent Ansible role that installs and configures Nginx on Ubuntu.

Requirements:

  • Use dedicated modules instead of shell or command wherever possible.
  • Fully qualified collection names on every module.
  • Restart only when the configuration actually changes — use a handler.
  • Validate the rendered configuration before installing it, so a bad template fails the task rather than breaking the running service.
  • Declare every variable the role reads in defaults/main.yml.
  • Explicit file modes as quoted strings.
  • become only on the tasks that need it, not at play level.
  • No secrets anywhere in the role.

The tasks

- name: Install the web server package
  ansible.builtin.package:
    name: "{{ web_package }}"
    state: present
  become: true
  # A module, not `shell: apt-get install`. The module reports `changed` only
  # when it actually changed something, which is what makes a rerun a no-op.

- name: Render the site configuration
  ansible.builtin.template:
    src: site.conf.j2
    dest: "{{ web_config_path }}"
    owner: root
    group: root
    mode: "0644"
    # Refuse to install a configuration the server cannot parse. Without this,
    # a bad template restarts nginx into a failed state and takes the site
    # down; with it, the task fails and the running server is untouched.
    validate: "nginx -t -c %s"
  become: true
  notify: Reload web server

- name: Ensure the web server is running and enabled
  ansible.builtin.service:
    name: "{{ web_service }}"
    state: started
    enabled: true
  become: true

Three things here are the whole lesson.

validate: on the template task. Ansible renders to a temporary file, runs the command with %s replaced by that path, and only installs the file if the command succeeds. This single line is the difference between a typo causing a failed task and a typo causing an outage — and no suggestion includes it unprompted.

notify: rather than a restart task. The handler runs once, at the end of the play, and only if the template actually changed. A state: restarted task restarts on every run.

state: started rather than state: restarted. Converging on a desired state rather than performing an action. This is the distinction the whole tool is built on.

The handler

- name: Reload web server
  ansible.builtin.service:
    name: "{{ web_service }}"
    state: reloaded
  become: true

reloaded rather than restarted where the service supports it: a reload re-reads configuration without dropping connections. Generated handlers use restarted by default.

The inventory assertion

pre_tasks:
  - name: Refuse to run against an unexpected inventory
    ansible.builtin.assert:
      that:
        - inventory_hostname in groups['web']
      fail_msg: >-
        {{ inventory_hostname }} is not in the `web` group. Check the -i
        argument before rerunning.
    tags:
      - always

This is the cheapest safety control in this lesson. A playbook that fails immediately when pointed at the wrong inventory has prevented the most expensive category of Ansible mistake, and it costs eight lines.

Observed output

$ ansible-playbook --syntax-check -i inventory.ini site.yml
playbook: site.yml

$ ansible-lint
Passed: 0 failure(s), 0 warning(s) in 8 files processed of 10 encountered.
Last profile that met the validation criteria was 'production'.

Validation

--check is not what people think

ansible-playbook --check is described as a dry run and is usually understood as “nothing happens”. What actually happens:

  • Ansible connects to every host in the inventory.
  • It gathers facts, which executes a module on each host.
  • Modules that support check mode report what they would do; modules that do not are skipped, so the output is incomplete.
  • shell: and command: tasks are skipped entirely by default, which means a playbook built on them reports almost nothing.

So --check is a useful preview against hosts you are entitled to connect to, and it is not a local validation step. It was not run for this example, because there is no disposable target host here — and a check run against localhost would only have tested the control node.

The genuinely local gates are --syntax-check and ansible-lint, which is why those are the two the pipeline uses.

Idempotency, concretely

The test is mechanical: run the playbook twice and require the second run to report changed=0.

That single assertion catches most non-idempotent generated tasks, and it is worth wiring into a pipeline against a disposable container. The usual culprits:

command or shell without changed_when. Every run reports changed. Add changed_when: false for a task that only reads, or a condition based on the command’s output for one that may act.

lineinfile where template belongs. Three lineinfile tasks editing the same file are three chances to produce a file no single source describes. A template is declarative: the file is what the template says.

state: restarted or state: latest. Both are actions rather than states. latest on a package means the playbook’s behaviour depends on when it runs.

A creates: argument that is missing. command: with creates: skips entirely when the file exists, which is the simplest way to make an unavoidable command idempotent.

Facts assumed rather than gathered. A task conditional on ansible_distribution in a play with gather_facts: false silently evaluates against an undefined variable.

Templates and Jinja2

Templates are where most of a role’s actual content lives, and Jinja2 is the part of Ansible where a generated mistake is quietest.

Undefined variables render as empty by default. A template referencing web_worker_processes when the variable is spelled worker_processes in defaults/ produces a config file with a blank value rather than an error. Depending on the service, that is a default, a parse error, or a silent misconfiguration. Setting ANSIBLE_UNDEFINED_VAR_BEHAVIOR or using | mandatory on values that must be present converts it into a failure, and the validate: argument on the template task catches the rest.

Whitespace control changes the output. {%- ... -%} versus {% ... %} decides whether a loop leaves blank lines behind. For most config formats this is cosmetic; for a few — anything indentation-sensitive, or a file consumed by something strict — it is not. Generated templates rarely use the trimming forms, and the result is a rendered file with unexpected gaps.

Filters need checking, not just accepting. | default(), | to_nice_json, | combine(), | regex_replace() and | b64decode are all things Copilot uses fluently. The two worth reading carefully are default() — which by default only substitutes when the variable is undefined, not when it is empty, so default(x, true) is needed for the falsy case — and any regex_replace on a value that reaches a config file, because a wrong pattern silently produces the wrong string.

Always include ansible_managed. A comment at the top of the rendered file saying it is managed and that local edits are overwritten. It costs one line and it saves the incident where someone fixes a production config by hand and the next run reverts it.

The verification that catches most template problems is cheap:

ansible-playbook --check --diff -i inventory.ini site.yml

--diff prints the exact textual change the template would make. That is the closest thing Ansible has to a plan, and reading it takes seconds.

Testing roles

Ansible roles are testable and almost nobody tests them, which is why generated roles are accepted on the strength of a lint run.

The two-run test is the minimum, and it needs no framework: apply the role to a disposable container, apply it again, and require changed=0. Wire that into CI against a Docker image of the target distribution and you have caught the entire non-idempotency class.

Molecule is the mainstream framework, and it wraps exactly that pattern: create an ephemeral instance, converge the role, assert on the result, then run idempotence as a distinct step. Copilot writes Molecule scenarios reasonably when asked, and will not produce one unprompted.

Assertions belong in the role, not only in tests. The pre_tasks inventory check above is one; ansible.builtin.assert on the variables a role requires is another, and it converts a failure that would otherwise surface halfway through a run into one that happens before any host is touched.

Two things to ask for specifically, because they are the difference between a test that proves something and one that proves the playbook ran:

Copilot promptTests that can actually failCopilot Chat

Write a Molecule scenario for this role.

The verify step must assert on observable end state — the service is enabled and listening, the config file exists with the expected mode and owner, and the rendered file contains the value from the variable I set.

Do not assert that a task reported changed. Include the idempotence step.

“Do not assert that a task reported changed” matters: a generated verify step frequently checks Ansible’s own report rather than the state of the host, which tests the tool rather than the role.

Roles, variables and precedence

Copilot writes correct role structure and is weak on variable precedence, which is the part of Ansible that surprises people.

defaults/main.yml is the lowest precedence and is where a role’s interface belongs. vars/main.yml is much higher and overrides inventory variables, which is almost never what you want — a role that puts its configurable values there cannot be configured by its caller. Generated roles do this regularly.

The practical rule: if a caller might reasonably want to change it, it goes in defaults/. vars/ is for internal constants the role needs and nobody should override.

Two related checks in generated roles. Are all the variables the tasks reference actually declared in defaults/? An undefined variable produces a runtime error on the host rather than a validation failure. And does the role namespace its variables — web_port rather than port — since role variables share one flat namespace and a generic name will collide.

Ansible-specific risks

Wrong inventory. The most expensive. The pre_tasks assertion above is the mitigation.

shell/command overuse. Non-idempotent, non-portable, and skipped by check mode.

Plaintext secrets. Covered below. ansible-lint will not find them.

become: yes at play level. Everything runs as root.

Unrestricted host patterns. hosts: all in a playbook that should target one group.

Outdated module syntax. The key=value shorthand, unqualified module names, and modules that moved into collections. Public playbooks span many years.

Destructive handlers. A handler that restarts a database, notified by a task that touches a config file, fires during what was meant to be a no-op run.

ignore_errors: true. Turns a failure into a silent continuation, usually added to make a playbook “work”.

Destructive commands

Ad-hoc commands deserve particular care because they bypass every safety mechanism a playbook has. --limit and a read-only command first is the habit.

Review workflow

Accepting generated Ansible
  1. ansible-playbook --syntax-checkLocal, fast, no hosts contacted. Confirms it parses and the role structure resolves.
  2. ansible-lintNineteen findings on the draft here. Run it at the production profile and treat findings as errors.
  3. Read every shell and command taskHuman judgementIs there a module? If not, does it have changed_when and creates?
  4. Grep for anything that looks like a secretHuman judgementansible-lint will not find it. This step is not optional.
  5. Check hosts and become scopeHuman judgementWhich group does this target, and does every task need root?
  6. Run twice against a disposable hostThe second run must report changed=0. This is the idempotency test.
  7. Then --check --diff against the real targetHuman judgementRemembering that this connects and gathers facts.

Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.

Best practices

  • Ask for modules rather than shell, and for changed_when where command is unavoidable.
  • Put a pre_tasks inventory assertion in every playbook.
  • Use template: with validate: for any service configuration.
  • Handlers for restarts; state: started, not state: restarted.
  • Declare the role’s interface in defaults/main.yml, not vars/.
  • Vault the secrets, and add no_log: true to the tasks that touch them.
  • Test idempotency by running twice and requiring changed=0.

Common mistakes

  • Accepting shell: because it works, then discovering the playbook can never run quietly.
  • Believing --check is a local dry run.
  • Restarting on every run instead of notifying a handler.
  • Putting configurable values in vars/ where a caller cannot override them.
  • Assuming a clean ansible-lint run means there are no secrets in the repository.

Where to go next

GitHub Copilot for Linux Administration covers the systems underneath these playbooks and the diagnostic discipline that goes with them. GitHub Copilot for Python is relevant because Ansible modules are Python, and GitHub Copilot for Terraform is the other half of most infrastructure estates — provisioning with one, configuring with the other.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources