TT Lab
Get started
Learn Learning paths Courses

Ansible Fundamentals

file, copy and blockinfile: declaring what a path must look like

Continue in TT Lab

In one sentence

A file module is the place to write not "run this command" but "this path must be in this state", and the single word state is the whole state machine. The rest is a matter of the permissions, backup, validation, and markers that come with that state.

Why this was needed

In the days of handling servers by hand, deploying a configuration was four lines: mkdir -p /etc/app, cp app.conf /etc/app/, chmod 640 /etc/app/app.conf, sed -i 's/8080/9090/' /etc/app/app.conf. If you move these four lines as they are into a shell task, it looks as if you have automated it, but in reality three things collapse at once.

First, the second run differs from the first. sed -i is reported as changed even when run again on a file that is already 9090, and if the pattern appears twice, it changes it twice. Second, you cannot tell what changed. cp overwrites and is done, so nothing remains anywhere of what was there before. When one wrong configuration causes an outage, the basis for reverting is gone. Third, a broken configuration goes up as it is. Whether it is JSON with bad syntax or an nginx configuration, cp uploads it without asking. The service dies at the next restart, and by then a good while has passed since the deployment.

Ansible's file modules answer each of these three problems with one argument. Idempotency is handled by state and the module itself, the basis for reverting by backup, and blocking a broken configuration by validate. Replacing the four shell lines with modules is not a matter of style but a matter of whether you get these three things or not.

How it works

state — the state machine of file modules

ansible.builtin.file seems to have many arguments, but the skeleton is the single state.

state Meaning When missing When already so
directory Must be a directory Creates it, including intermediate paths ok
file Only adjusts the attributes of an existing file Fails (does not create it) Compares attributes only
touch Creates an empty file if missing Creates it Updates mtime, so always changed
link Must be a symbolic link Creates it ok if the target is the same
hard Must be a hard link Creates it ok if the inode is the same
absent Must not exist ok Deletes it (a directory entirely)

Two cells here catch people. state: file does not create files — a task that only meant to fix permissions fails saying "the path does not exist". And state: touch is not idempotent. Even if the file already exists, it touches mtime and gives changed every time. If you want changed to be 0 on the second run, either don't use touch, or, if you do, give modification_time: preserve and access_time: preserve along with it.

state: absent deletes a directory recursively. There have been several incidents where this one line wiped out an unintended directory because of a single typo in a path variable. For a deleting task, it is safer not to assemble the path from variables, and if you must assemble it, put one task in front that checks the prefix with assert.

mode — a single quotation mark changes the permission

This is the most frequent incident, and it happens quietly.

- ansible.builtin.copy: {dest: /root/demo/a, content: "x\n", mode: "0640"}   # → 0640
- ansible.builtin.copy: {dest: /root/demo/b, content: "x\n", mode: 0644}     # → 0644
- ansible.builtin.copy: {dest: /root/demo/c, content: "x\n", mode: 644}      # → 1204

The third line is the problem. YAML reads a number with no leading 0, such as 644, as decimal 644, and the module writes that integer as it is into the permission bits. Decimal 644 is 1204 in octal, and the leading 1 is the sticky bit. The result is a permission that is --w----r-- with sticky attached, which nobody ever intended. No error or warning appears. So the rule is just one — always write mode as a quoted string. Like "0640". Symbolic notation such as u=rw,g=r,o= is also a string and therefore safe, and for people to read it is actually better.

In the file module's mode, an uppercase X can also be used. u=rwX,g=rX,o=rX means "give the execute bit only to directories or files that already have execute permission for someone", so it is suitable for applying to a directory tree in one go with recurse: true.

For owner and group, if you give names, they are resolved on the target host. A frequent snag is that it must be a user that exists on the target, not a user on the controller, and that the task fails if that user does not exist on the target.

copy — src and content, and backup and validate

copy takes two inputs. src sends a file from the controller, and content writes a string as the content on the spot. The two cannot be used together. For a short configuration content reads well, and for a long file or a binary src is right. If values have to go in, it is the place to move on to template, not the place to cram long Jinja into content.

If you give backup: true, the content just before the overwrite is left in the same directory. The name takes the form of app.conf.416.2026-09-17@05:16:32 followed by a tilde, so the original and the backup appear side by side. The path is held in backup_file of the return value, so if you receive it with register, you can use it straight away in a rollback task. Remember that the backup remains on the target host — to bring it to the controller, fetch is needed separately afterward.

validate is a contract: "only what passes the check is put in place". The temporary file path goes into the %s spot in the string, and only if that command ends with 0 is it moved to the target path. If it fails, the task fails and no file appears at the target path. If there was an existing file, that file remains as it is. visudo -cf %s, nginx -t -c %s, and python3 -c "import json,sys; json.load(open(sys.argv[1]))" %s are common forms. Two easy mistakes here — if you leave out %s, the check command looks at the wrong file, and if the check command has side effects such as sudo or a service restart, a failed deployment leaves only the side effects.

blockinfile — the marker is the key to idempotency

Sometimes you need to lay a multi-line section inside someone else's configuration file. Examples are a few internal host lines in /etc/hosts or a few lines of our policy in sshd_config. If you repeat lineinfile for as many lines as there are, three things collapse. The order and adjacency between lines are not guaranteed, there is no way to delete the section as a whole later, and if one line is already in a different context, a wrong place matches.

blockinfile solves this by leaving markers before and after the managed section.

- ansible.builtin.blockinfile:
    path: /etc/hosts
    marker: "# {mark} ANSIBLE MANAGED BLOCK: internal pool"
    block: |
      10.10.0.11 web1
      10.10.0.12 web2

Into the {mark} spot, BEGIN and END go respectively. On the next run, the module treats only the area between the markers as its own and replaces everything inside. It does not touch the outside. If you give state: absent, it deletes the markers and what is between them together.

The most common incident here is changing the marker string later. If the marker changes, the module does not recognize the old block as its own and creates one more new block. The same content remains twice in the file, and one of the two is never managed again. This is also the reason you must give different names to marker when you put more than one block in a file — there is only one default marker, so a later task overwrites an earlier block.

Boundaries — what to use when

Situation What to use
The whole file is ours copy or template
One key=value line in someone else's file lineinfile
A multi-line section in someone else's file blockinfile
Replace every pattern inside a file replace
Only check existence, permissions, and hash stat
Bring a file from the target to the controller fetch

stat changes nothing and only returns facts. If you receive it with register, you can see .stat.exists, .stat.mode, .stat.size, .stat.isdir, .stat.islnk, and, if you gave checksum_algorithm, .stat.checksum. When you use it as the basis for a conditional branch, as in when: st.stat.exists, the habit of looking at exists first is important. If the path does not exist, keys such as mode themselves do not exist, so the moment you access them you get an undefined-variable error.

fetch is the opposite direction of copy. It brings a file on the target host to the controller. The default behavior is unusual, so you are surprised once — under dest, it creates a host-name directory and reproduces the original's full path as it is to hold it. With dest: /root/backup/, it becomes /root/backup/web1/etc/app/app.conf. It is designed so that files do not get mixed up when you collect the same file from several machines. If there is only one machine or you want to decide the name yourself, give flat: true and write dest as a file path.

Links and follow

state: link creates a symbolic link and state: hard creates a hard link. The place where the difference between the two shows up in practice is when you replace the original. copy does not modify a file in place — it writes to a temporary file and swaps the name. So if you redeploy the original with copy, a new inode is created, and the hard link is left on the old inode. On the next run, the state: hard task fails saying "a file already exists at the destination". A symbolic link points to a path, so it does not have this problem. This is why the current symbolic link pattern commonly used in deployments is not a hard link.

follow decides "when the path is a symbolic link, do you look at the link itself or at the file at its end". The file module defaults to follow: true, so if you set mode on a link, it is the original's permission that changes, not the link's. On Linux the permission of a symbolic link itself is meaningless, so this side is usually right, but this is where the incident "I only wanted to re-hang the link but the original changed" comes from. stat is the opposite, with a default of follow: false, so it looks at the link itself — just remember that an argument with the same name has a different default in each module.

What you see in the field

Case 1 — the day permission 640 came out as 1204. In a role that deploys a secret key, mode: 600 was written without quotes. The permission that actually remained was 1170, and read permission was open to the group. The reason nobody noticed is simple — the deployment succeeded, and the application ran as root and had no problem reading the file. It was discovered half a year later in a security review. Since that day, one lint rule has been put in CI. If the value after mode: is not wrapped in quotes, it fails the build.

Case 2 — a configuration with two sets of blocks. In a role that puts internal hosts in /etc/hosts, a commit went up that changed the marker wording from "ANSIBLE MANAGED BLOCK" to "MANAGED BY PLATFORM". On the next deployment, the same lines appeared twice in the /etc/hosts of every server. The old block had a different marker and was no longer managed by anyone, and later, when one host's IP changed, only the new block was updated, so the old line won. A marker is an interface that stays in the file. If you are going to change it, first run state: absent once with the old marker to delete the block, and then change it.

Case 3 — the price of not attaching validate. In a configuration template, one variable was empty and the rendered JSON was deployed broken. The deployment was green and the service ran fine — because the moment of reading that configuration was the next restart. Eight hours later a node restart hit, and half of them died. If there had been one validate line, the deployment would have failed right there, and it would have ended as a single failed pipeline instead of an outage.

What you will do in the next lab

With file and its state, you declare a directory tree, and measure for yourself the permission that a mode without quotes leaves and confirm it with your own eyes. To copy, you attach backup and validate to leave a basis for reverting and to block a broken configuration. With blockinfile, you make a section with markers and confirm that there is only one block even when run twice, and create symbolic and hard links to see what follow changes. Finally, you collect state with stat and fetch to build a report, and write yourself an inspection script that takes a list of paths and judges what state each path is in now.

References