GitHub Copilot for 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 filesAnd 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.
Key takeaways
- The draft playbook contained
line: "db_password=hunter2". ansible-lint never mentioned it. Its only use of the word “password” was a complaint that the task name should start with a capital letter. - Idempotency is the requirement. Use a module rather than
shell:; ifcommand:is unavoidable it needschanged_whenand usuallycreates. --checkis not a dry run in the sense people assume. It still connects to every host and gathers facts.- Start every playbook with a
pre_tasksassertion that the inventory is what the operator expects. Running against the wrong inventory is the most expensive Ansible mistake there is. template:withvalidate:refuses to install a configuration the service cannot parse, which turns a bad render into a failed task instead of a restarted-into-failure service.
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.
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
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
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: trueThree 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: truereloaded 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:
- alwaysThis 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
Validate before deploying
Ansible: the local validation sequence
| Step | Command | Executed here |
|---|---|---|
| Syntax check | ansible-playbook --syntax-check -i inventory.ini site.yml | PASSplaybook: site.yml — parsed with no errors |
| Lint | ansible-lint site.yml | PASSPassed: 0 failure(s), 0 warning(s), at the production profile; the draft produced 19 failures |
| Check mode | ansible-playbook --check -i inventory.ini site.yml | NOT RUNCheck mode still connects to hosts and runs fact gathering. Not run: there is no disposable target host here. |
--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:andcommand: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:
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
- ansible-playbook --syntax-checkLocal, fast, no hosts contacted. Confirms it parses and the role structure resolves.
- ansible-lintNineteen findings on the draft here. Run it at the production profile and treat findings as errors.
- Read every shell and command taskHuman judgementIs there a module? If not, does it have changed_when and creates?
- Grep for anything that looks like a secretHuman judgementansible-lint will not find it. This step is not optional.
- Check hosts and become scopeHuman judgementWhich group does this target, and does every task need root?
- Run twice against a disposable hostThe second run must report changed=0. This is the idempotency test.
- 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 forchanged_whenwherecommandis unavoidable. - Put a
pre_tasksinventory assertion in every playbook. - Use
template:withvalidate:for any service configuration. - Handlers for restarts;
state: started, notstate: restarted. - Declare the role’s interface in
defaults/main.yml, notvars/. - Vault the secrets, and add
no_log: trueto 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
--checkis 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-lintrun 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.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.