AnsibleXVIII · Files and Configuration ManagementFiles and configuration management
file and the meaning of state
What you'll learn
- Distinguish the six documented values of state and the operation each performs
- Explain why state: file never creates a file and what to use instead
- Predict what recurse does and does not apply to
- Use state: absent safely, including what it will silently remove
- Verify a symlink is a symlink rather than a copy of its target
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
ansible.builtin.file does not write file contents. It manages
everything around the contents: existence, type, ownership,
permissions, SELinux context, timestamps, and extended attributes. One
option decides which of those operations you get.
$ ansible-doc ansible.builtin.file | grep -A2 'choices:' choices: [absent, directory, file, hard, link, touch]
default: null
type: strRead that list as six verbs, not six adjectives.
state | Operation | Creates? | Can destroy? |
|---|---|---|---|
file | Set attributes on an existing path | No | No |
directory | Create the path and all intermediate directories | Yes | No |
link | Create or repoint a symbolic link | Yes | With force |
hard | Create or change a hard link | Yes | With force |
touch | Create empty, or update access and modification times | Yes | No |
absent | Unlink a file or link; recursively delete a directory | No | Yes |
state: file does not create a file
This is the one that surprises people, and the documentation is blunt about it:
If
file, even with other options (such asmode), the file will be modified if it exists but will NOT be created if it does not exist. Set totouchor use theansible.builtin.copyoransible.builtin.templatemodule if you want to create the file if it does not exist.
The task fails if the path is missing. That is deliberate: state: file
means “this path is a regular file and has these attributes”, and a
missing path cannot satisfy that with an empty file you never asked for.
directory and what recurse covers
state: directory creates every intermediate directory, like
mkdir -p, and applies the supplied permissions to the ones it creates.
recurse: true extends the attribute application to the directory’s
existing contents — and the documentation constrains it tightly:
Recursively set the specified file attributes on directory contents. This applies only when
stateis set todirectory.
Two consequences worth stating plainly.
recurse never deletes anything. It is not rsync --delete. Files
in the directory that your automation did not create keep existing; they
just acquire your ownership and mode.
recurse applies one mode to files and directories alike. That is
usually wrong. mode: '0644' on a recursed tree strips the execute bit
from every directory, which makes the directory untraversable. The
symbolic form solves it, because capital X sets the execute bit only
where it is already set on something, or on directories.
# Wrong: 0644 on a tree makes every directory non-traversable.
- name: Fix ownership of the app tree
ansible.builtin.file:
path: /opt/app
state: directory
owner: appsvc
group: appsvc
mode: '0644'
recurse: true
# Right: u=rwX,g=rX,o= - the capital X only touches directories and
# files that already carried an execute bit.
- name: Fix ownership of the app tree
ansible.builtin.file:
path: /opt/app
state: directory
owner: appsvc
group: appsvc
mode: u=rwX,g=rX,o=
recurse: truelink, hard, and force
state: link creates a symbolic link at path pointing at src. The
option that decides how aggressive it is:
force: Force the creation of the links in two cases: if the link type is symbolic and the source file does not exist (but will appear later); the destination exists and is a file (so, we need to unlink thepathfile and create a link to thesrcfile in place of it).
That second clause is the one to read twice. force: true will unlink
a real file and replace it with a symlink. That is exactly what you
want when converting a config file into a link to a shared one, and
exactly what you do not want when the path was a real file holding data.
- name: Point current at the new release
ansible.builtin.file:
src: /opt/app/releases/{{ app_release }}
dest: /opt/app/current
state: link
owner: appsvc
group: appsvc
notify: Reload the applicationfollow interacts with this and defaults to true on the file
module. With follow: true and state: link, setting mode can modify
the target of the link rather than the link itself, which the
documentation names explicitly. When you are creating a link to a
destination that does not exist yet, set follow: false to avoid the
permission warning the module raises about a path it cannot stat.
absent, and the size of the hole it makes
state: absent is the only value on this module that destroys data, and
what it destroys depends on what the path turned out to be:
If
absent, directories will be recursively deleted, and files or symlinks will be unlinked.
A templated path that resolves one level higher than intended does not
fail — it succeeds, recursively, and reports changed. There is no
confirmation and no --force gate.
The module also documents that absent does not fail when the path
does not exist, because the state did not change. That is correct
idempotency and it also means a typo in the path is invisible: the task
reports ok forever against a path that was never there.
touch and the check-mode footnote
state: touch creates an empty file if the path is missing, and updates
the access and modification times if it is not. Because updating a
timestamp is a change by definition, a touch task reports changed
on every run unless you constrain it with modification_time: preserve and access_time: preserve.
That perpetual changed is not cosmetic. It notifies handlers, it
inflates the change count an auditor reads, and it means a run that
should have been quiet never is. If you are using touch to create a
log file, copy with content: '' and force: false states the intent
better and stays ok.
Verifying the result
Whatever the play claims, stat on the host is the fact. It is
read-only and it is the right first move in any diagnosis.
ansible -i inventories/prod app -m ansible.builtin.stat \
-a "path=/opt/app/current follow=false" \
| grep -E '"islnk"|"isdir"|"isreg"|"lnk_target"|"nlink"|"mode"'$ ansible -i inventories/prod app -m ansible.builtin.stat -a 'path=/opt/app/current follow=false'app01.example.com | SUCCESS => {
"stat": {
"islnk": true,
"isdir": false,
"lnk_target": "/opt/app/releases/2026-08-04-9f3c1ab",
"mode": "0777"
}
}
app02.example.com | SUCCESS => {
"stat": {
"islnk": false,
"isdir": true,
"mode": "0755"
}
}Illustrative output
The 0777 on the symlink is normal and not a finding: on Linux, symlink
permission bits are not consulted, and stat reports them as 0777.
The finding is app02, where islnk is false.
Knowledge check
Knowledge check · 4 questions
Q1. A task uses state: file with mode 0600 against /etc/app/token. On twelve of forty hosts the application has never started and the file does not exist. What happens on those twelve?
Q2. state: absent against a path that turns out to be a directory deletes the directory and everything inside it, without any additional flag.
Q3. A task sets mode: 0644 with recurse: true on /opt/app to fix ownership. What breaks?
Q4. Which statements about state: link with force: true are correct? Select all that apply.
Passing score: 75%. Answers are checked in this browser.