Skip to main content
RunBook Academy

AnsibleXVIII · Files and Configuration ManagementFiles and configuration management

Owning a region of a file you do not own

Intermediate⏱ ~18 minansible-playbookansible-doc

What you'll learn

  • Design a marker that survives multiple blocks in one file and multiple runs
  • State the three documented marker rules that cause a block to be inserted repeatedly
  • Predict what happens to a human edit made inside a managed region
  • Place a block deterministically with insertafter and insertbefore
  • Remove a managed region cleanly, including its markers

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.

blockinfile is the middle position in this part’s argument. template declares an entire file and needs to own it. lineinfile declares one line and declares nothing about the rest. blockinfile declares a named region, and everything outside that region belongs to somebody else.

That makes it the right tool for a specific and very common situation: a file that a package owns, that a human also edits, and into which your automation needs to contribute several related lines that belong together.

The markers are the contract

The default marker is # {mark} ANSIBLE MANAGED BLOCK, with {mark} replaced by BEGIN and END. Rendered into a file, a managed region looks like this:

# BEGIN ANSIBLE MANAGED BLOCK
Match User deploy
    PasswordAuthentication no
# END ANSIBLE MANAGED BLOCK

The module locates its region by finding those two lines. It replaces everything between them and touches nothing else. That is the whole mechanism, and every failure mode below is a consequence of it.

The three marker rules that cause repeated insertion

All three are documented, and all three produce the same symptom: the block is added again on every run, and the file grows.

One — the marker must contain {mark}.

Using a custom marker without the {mark} variable may result in the block being repeatedly inserted on subsequent playbook runs.

Without {mark}, the begin and end markers are the same string, so the module cannot delimit a region and fails to find its own previous block.

Two — markers cannot be multi-line.

Multi-line markers are not supported and will result in the block being repeatedly inserted on subsequent playbook runs.

A marker written with a YAML block scalar, or containing a \n, is not a marker. The module appends a newline to marker_begin and marker_end itself.

Three — one marker per block, per file.

When more than one block should be handled in one file you must change the marker per task.

Two tasks with the default marker against the same file do not create two regions. The second task finds the first task’s markers and overwrites the region between them. Each run, both tasks fight over one region, and both report changed forever.

Configuration changetwo regions in one file, done correctly
- name: Manage the SSH match block for the deploy account
ansible.builtin.blockinfile:
  path: /etc/ssh/sshd_config
  marker: "# {mark} ANSIBLE MANAGED - deploy account"
  block: |
    Match User deploy
        PasswordAuthentication no
        AllowTcpForwarding no
  validate: /usr/sbin/sshd -t -f %s

- name: Manage the SSH match block for the backup account
ansible.builtin.blockinfile:
  path: /etc/ssh/sshd_config
  marker: "# {mark} ANSIBLE MANAGED - backup account"
  block: |
    Match User backup
        PasswordAuthentication no
        ForceCommand /usr/local/bin/backup-only
  validate: /usr/sbin/sshd -t -f %s

The same rule applies inside a loop. The module notes it explicitly for the legacy with_* form, and it holds for loop just as much: if the marker is constant across iterations, each iteration overwrites the last, and the file ends up holding only the final item.

Configuration changea loop needs the marker in the loop
- name: Manage per-service host entries
ansible.builtin.blockinfile:
  path: /etc/hosts
  marker: "# {mark} ANSIBLE MANAGED - {{ item.name }}"
  block: |
    {{ item.ip }} {{ item.name }} {{ item.name }}.example.com
loop:
  - { name: cache01, ip: 192.0.2.31 }
  - { name: cache02, ip: 192.0.2.32 }

Comment syntax is per-file, not universal

The default marker begins with #, which is a comment in shell, YAML, INI, most config formats and no XML or HTML file anywhere. A marker that is not a comment in the target file’s syntax is a syntax error you wrote deliberately, and the module will happily install it.

File typeMarker
Shell, INI, most .conf# {mark} ANSIBLE MANAGED - <owner>
HTML, XML<!-- {mark} ANSIBLE MANAGED - <owner> -->
C-family, JSON5, some agent configs// {mark} ANSIBLE MANAGED - <owner>
SQL-- {mark} ANSIBLE MANAGED - <owner>
Strict JSONnone exists — do not use this module

JSON has no comment syntax. There is no marker that a JSON parser will accept, so a managed region cannot be expressed. Template the whole file, or build the structure as a variable and render it with to_nice_json.

Placement: insertafter and insertbefore

These are consulted only when the markers are not already present — that is, on the first run, or after someone deleted the region. Once the markers exist, the block stays exactly where they are, which is the behaviour you want: a human who moved your region to a more sensible place in the file does not get it moved back.

If insertafter finds no match, the block goes to EOF. If insertbefore finds no match, the block goes to the end of the file as well. Both fall back to the end silently — the same failure shape as lineinfile, with the same consequence for section-scoped settings.

Since ansible-core 2.14 the anchor regex honours the multiline flag: (?m) in the pattern makes the match run line by line, and without it the pattern is applied across the file as one string. That is what makes a multi-line anchor possible.

append_newline and prepend_newline (both default false) add a blank line after and before the inserted block if one is not already there. They matter more than they look: in files where a directive must be separated from a preceding stanza, a block welded onto the previous line changes its meaning.

What happens when a human edits inside the region

They lose the edit, silently, on the next run.

That is the correct behaviour and it is the point of the markers, but the practical failure is worth naming: the human did not know the region was managed, because the only signal was a comment they read as decoration. When the change reverts, the usual first theory is that the edit did not save.

Removing a region

Two forms do the same thing, and both remove the markers along with the content:

state: absent

or, equivalently per the documentation, block: "" — “if it is missing or an empty string, the block will be removed as if state were specified to absent”.

That equivalence is a trap in one specific case: a block built from a variable that renders empty removes the region rather than writing an empty one. A block fed by {{ some_list | join('\n') }} where the list is empty deletes the managed region across the fleet and reports changed, which looks exactly like a successful update in the recap.

If a region may legitimately be empty, guard the task with a when on the variable rather than letting an empty render mean deletion.

Knowledge check

Knowledge check · 4 questions

  1. Q1. Two blockinfile tasks in the same role write to /etc/ssh/sshd_config, both using the default marker. What happens?

  2. Q2. A blockinfile task whose block renders to an empty string removes the managed region and its markers entirely.

  3. Q3. A managed region needs to go into an HTML file. What must change from the default configuration?

  4. Q4. Which of these cause a blockinfile task to insert its block again on every run? Select all that apply.

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