Choose the Module, Not the Command
Scenario: A deploy playbook builds its config with shell: echo ... >> app.conf. After six months the file has 174 lines, 170 of which are duplicates, and the application reads whichever it finds first.
New words, in plain English
- Module - a purpose-built unit of work that inspects the target before acting.
filemodule - manages paths: create a directory, remove something, set ownership or permissions.copymodule - puts literal content or a local file onto the target, comparing checksums first.templatemodule - the same, but renders the file through Jinja2 first so it can contain variables.- Append - adding to the end of a file - the operation that duplicates on every re-run.
A module knows what it is trying to achieve, so it can check reality first. copy computes a checksum of the intended content and the existing file, and does absolutely nothing if they match. file with state: directory checks whether the directory already exists with the right mode. This is what "idempotent" means in practice.
shell and command know nothing. echo x >> f appends unconditionally, every single run, forever. mkdir -p d at least does not fail, but it still reports changed every time, and anything that depends on that result - most importantly a handler - fires every time too.
The rule is simple and worth applying strictly: if a module exists for the job, use it. file instead of mkdir/chmod/rm, copy or template instead of echo/cat, lineinfile/blockinfile for surgical edits to files you do not fully own, service/systemd instead of systemctl, user/group instead of useradd.
Analogy:copyis a proofreader who compares your draft with the printed page and only reprints if they differ.echo >>is a printing press with the safety removed - it runs off another copy every time you walk past, whether or not one is needed.
A worked example
# WRONG - appends forever, reports changed forever
- name: Write the settings
ansible.builtin.shell: |
mkdir -p ~/srv/checkout
echo "listen_port = 8080" >> ~/srv/checkout/config.ini
# RIGHT - declares the end state, converges once, then does nothing
- name: The application directory exists
ansible.builtin.file:
path: ~/srv/checkout
state: directory
mode: "0750"
- name: The settings file has exactly this content
ansible.builtin.copy:
dest: ~/srv/checkout/config.ini
content: |
listen_port = 8080
worker_count = 4
mode: "0644"
There is a second, quieter benefit. Because modules report what they did in a structured way, --check and --diff work properly with them: Ansible can tell you exactly which lines of a file would change before you change them. A shell task can offer none of that - in check mode it is simply skipped, and you learn nothing.
When you genuinely must run a command - a vendor installer, a bespoke migration tool, a health probe - that is legitimate. The next section is about making those tasks behave.
Warning:content:incopyreplaces the ENTIRE file. That is exactly what you want for a file your automation owns, and exactly what you do not want for/etc/hosts. For files you share with the system, uselineinfileorblockinfile.
Goal: Put this to work in the ansible-idempotent-modules lab. Open/labs/ansible, pickansible-idempotent-modules, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.