Skip to main content
RunBook Academy

AnsibleXVIII · Files and Configuration ManagementFiles and configuration management

Which file module, and why

Intermediate⏱ ~18 minansible-playbookansible-doc

What you'll learn

  • Choose between copy, template, file, lineinfile, blockinfile and assemble from the ownership of the file
  • State the two documented constraints on copy that push work towards template
  • Explain why a file managed by many line edits has no declared state
  • Identify the cases where a single line edit is the correct choice
  • Predict which module a reviewer can audit without logging into a host

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Six modules in ansible.builtin put content into a file on a managed node: copy, template, file, lineinfile, blockinfile and assemble. They are not six styles of doing the same thing. They differ in one property that matters more than any of their options:

how much of the file’s contents the automation declares.

template declares all of it. lineinfile declares one line and says nothing about the other four hundred. That difference decides whether a reviewer reading your repository can answer “what is in /etc/chrony.conf on web03?” without logging into web03.

The decision, stated first

ModuleDeclaresChoose it when
templateThe entire fileYou own the file and any part of it varies by host, group or environment
copyThe entire fileYou own the file and it is byte-identical everywhere
blockinfileA marked regionYou own part of a file that something else also writes
lineinfileOne lineYou need one setting changed in a file owned by a vendor or a package
assembleThe whole file, from fragmentsThe program has no conf.d directory and several roles must each contribute a stanza
fileMetadata onlyThe thing you are managing is a directory, a symlink, permissions, or an absence

Read that table as a preference order from the top. Each row down gives away a little more of your ability to say what the file contains.

Why “declared state” is the axis

Consider a /etc/ssh/sshd_config managed by eleven lineinfile tasks. Every one of them is idempotent. Every one has a regexp, a line, and a passing test. The play runs green.

Now answer this: is X11Forwarding disabled on your fleet?

You cannot tell from the repository. Eleven tasks say what eleven settings should be; the file has roughly ninety directives and a distribution default for each one you did not name. Nothing in your automation records the other seventy-nine. If a package upgrade changes a default, the run stays green and the behaviour changes.

Now the same file as a template. The answer is: open templates/sshd_config.j2 and read it. Every directive that will exist on the host is in that file, in review, in Git history, with a blame line naming who changed it and when.

copy versus template

copy transfers a file unchanged. template renders it through Jinja2 on the controller first, then transfers the result. Two documented constraints push more work towards template than people expect.

The first is about variables. The copy module’s content option sets a file’s contents from a string, and it is tempting to build that string from a variable. Upstream is explicit:

If you need variable interpolation in copied files, use the ansible.builtin.template module. Using a variable with the content parameter produces unpredictable results.

The reason is that content is typed str and receives whatever the templating layer produced — a multi-line string, a rendered data structure, or a type that stringifies differently than you expect. There is no rendering step you control. template gives you one.

The second is about scale. copy will recurse into a directory, and the module’s own notes cap that:

The ansible.builtin.copy module recursively copy facility does not scale to lots (> hundreds) of files.

The mechanism is in the action plugin: a recursive copy enumerates the tree on the controller and issues per-file operations. For a handful of files it is fine. For a release artefact of ten thousand files it is a slow, chatty run whose failure mode is a task that appears to hang. Ship an archive and use ansible.builtin.unarchive, or use ansible.posix.synchronize, and say in a comment why.

Configuration changethe same intent, three ways
# Wrong: content built from a variable. Documented as unpredictable.
- name: Write the chrony config
ansible.builtin.copy:
  dest: /etc/chrony/chrony.conf
  content: "{{ chrony_config_body }}"
  mode: '0644'

# Right, but only if the file is byte-identical on every host.
- name: Install the shared chrony config
ansible.builtin.copy:
  src: chrony.conf
  dest: /etc/chrony/chrony.conf
  owner: root
  group: root
  mode: '0644'

# Right when anything varies - the servers, the region, the drift file.
- name: Render the chrony config
ansible.builtin.template:
  src: chrony.conf.j2
  dest: /etc/chrony/chrony.conf
  owner: root
  group: root
  mode: '0644'
  validate: /usr/sbin/chronyd -f %s -p

When a single line is the right answer

The course’s preference for whole-file management is not a prohibition. There is a shape of problem where lineinfile is the correct and maintainable choice, and it has a clear test:

You do not own the file.

The package owns it. The vendor ships it. An upgrade will replace it, and you want that upgrade to bring its new defaults. You need exactly one setting different from the shipped value.

Examples that pass the test:

  • /etc/default/grub on a Debian-family host — package-managed, replaced on upgrade, and you want one kernel parameter.
  • A vendor agent’s /opt/vendor/agent.conf where the vendor’s support policy is void if you replace the file.
  • /etc/sysctl.conf on a system where a sysctl.d drop-in is not honoured by the boot sequence you are stuck with.

Examples that fail it, and are the ones you meet in practice:

  • sshd_config, nginx.conf, chrony.conf, rsyslog.conf, sudoers — you own the policy in every one of those, and every one is routinely mismanaged by a pile of line edits.

assemble, and the case for it

assemble concatenates a directory of fragments into one destination file. Upstream describes the problem it solves precisely:

Often a particular program will take a single configuration file and does not support a conf.d style structure where it is easy to build up the configuration from multiple sources.

Files are joined in string sorting order, which is why fragments are conventionally named 00-header, 10-auth, 90-footer. Its useful options are regexp to filter which fragments participate, ignore_hidden (default false, so editor backups do get included unless you set it), delimiter, and validate.

Configuration changeassemble with validation
- name: Build sudoers from role fragments
ansible.builtin.assemble:
  src: /etc/sudoers.d.fragments
  dest: /etc/sudoers.assembled
  remote_src: true
  ignore_hidden: true
  regexp: '^[0-9]{2}-'
  owner: root
  group: root
  mode: '0440'
  validate: /usr/sbin/visudo -cf %s

Its cost is that the destination file’s contents are declared by the set of fragments present on the host, which is one indirection further from the repository than a template. Use it when several independent roles genuinely must contribute, and the program refuses a drop-in directory. If one role owns the file, template it.

Reading the choice back out of a repository

The practical test for a reviewer is a grep, not an opinion.

Read-only / Safefind files managed line by line
grep -rhA5 'ansible.builtin.lineinfile\|ansible.builtin.blockinfile' roles/ \
| grep -oP '^\s+(path|dest):\s*\K\S+' \
| sort | uniq -c | sort -rn
Read-only / Safewhat the counts mean
$ grep -rhA5 'lineinfile' roles/ | grep -oP '(path|dest):\\s*\\K\\S+' | sort | uniq -c | sort -rn
     11 /etc/ssh/sshd_config
    6 /etc/security/limits.conf
    4 /etc/sysctl.conf
    1 /etc/default/grub

Illustrative output

The bottom line is fine: one setting, one vendor file. The top line is the finding. Eleven tasks against sshd_config is not eleven small problems, it is one file whose contents nobody has written down.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A role manages /etc/nginx/nginx.conf with nine lineinfile tasks. Every task is idempotent and the play reports ok on a second run. What is the defect?

  2. Q2. Which situations make lineinfile the right choice rather than a compromise? Select all that apply.

  3. Q3. Building a file from a variable with the copy module content parameter is equivalent to templating it, and is the lighter-weight option.

  4. Q4. A release drops 8,000 files that must land in /opt/app on 40 hosts. What does the copy module documentation say about this?

Passing score: 75%. Answers are checked in this browser.