Skip to main content
RunBook Academy

AnsibleX · YAML for Reliable AutomationYAML for reliable automation

File modes: quote them

Intermediate⏱ ~14 minansible-playbook

What you'll learn

  • State upstream guidance on quoting octal modes and the reason behind it
  • Compute the permission bits produced by an unquoted decimal mode
  • Explain why a leading zero works sometimes and not always
  • Choose between a quoted octal string and a symbolic mode
  • Verify the mode that actually landed on a managed node

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.

Upstream states the rule in the mode documentation of every file- touching module, and it is worth quoting verbatim because the wording is carefully hedged in a way that tells you something:

For consistent results, quote octal numbers (for example, '644' or '1777') so Ansible receives a string and can do its own conversion from string into number. Adding a leading zero (for example, 0755) works sometimes, but can fail in loops and some other circumstances. Giving Ansible a number without following either of these rules will end up with a decimal number which will have unexpected results.

“Works sometimes” is unusual language for reference documentation. This lesson is what is behind it.

The three forms and what they produce

Read-only / Safethe parsed value of each form
$ ansible-playbook -i localhost, yamltypes.yml
ok: [localhost] => (item=file_mode) => {
  "msg": "file_mode = 420 (type int)"
}
ok: [localhost] => (item=file_mode_quoted) => {
  "msg": "file_mode_quoted = 0644 (type str)"
}
ok: [localhost] => (item=plain_mode) => {
  "msg": "plain_mode = 644 (type int)"
}

The arithmetic

Read-only / Safethe bits each form actually sets
$ python3 modes.py
mode: 0644  -> YAML int 420    numeric=  420  octal=644  perms=-rw-r--r--
mode: 644   -> YAML int 644    numeric=  644  octal=1204  perms=--w----r-T
mode: '0644'-> int('0644',8)   numeric=  420  octal=644  perms=-rw-r--r--
mode: 0755  -> YAML int 493    numeric=  493  octal=755  perms=-rwxr-xr-x
mode: 755   -> YAML int 755    numeric=  755  octal=1363  perms=--wxrw--wt
mode: 0600  -> YAML int 384    numeric=  384  octal=600  perms=-rw-------
mode: 600   -> YAML int 600    numeric=  600  octal=1130  perms=---x-wx--T
loop "0420" -> int("0420",8)   numeric=  272  octal=420  perms=-r---w----

Three things fall out of that table.

mode: 644 is not a permission bug, it is a different file. Decimal 644 is octal 1204: the sticky bit set, the owner able to write but not read, the group with nothing, others with read. --w----r-T. A config file written that way is unreadable by the service that needs it and world-readable to everyone who does not.

mode: 755 is worse. Decimal 755 is octal 1363: --wxrw--wt. Sticky bit, group write, world write. On a directory that is a genuine security defect, and on a script it is a file anyone on the box can modify.

mode: 0644 produces the right answer for the wrong reason. YAML resolves 0644 as an octal literal to the integer 420, and 420 happens to be 0o644. The path taken is “text that looked octal, parsed as octal, used as a raw number” — and it lands on the correct value only because those two octal interpretations agree.

Why the leading zero “works sometimes”

The documented failure case is loops, and it is worth being precise about what does and does not happen in ansible-core 2.21.

Read-only / Safea whole-value template preserves the integer
$ ansible-playbook -i localhost, looptype.yml
TASK [Report the type that survived] *******************************************
ok: [localhost] => {
  "msg": "templated_mode=420 type=int"
}

TASK [Report that type too] ****************************************************
ok: [localhost] => {
  "msg": "concat_mode=mode-420 type=str"
}

So mode: "{{ item.mode }}" over a loop whose items carry mode: 0644 keeps the integer 420 and lands on -rw-r--r--. In this version, that particular loop is not the failure.

The failure is the moment the value stops being a whole-value template:

Configuration changethe concatenation that breaks it
# The item value is the integer 420 (from an unquoted 0644 in the loop data).
# Prefixing a zero produces the STRING "0420", which is parsed as octal 420.
- name: Set permissions
ansible.builtin.file:
  path: "{{ item.path }}"
  mode: "0{{ item.mode }}"
loop: "{{ config_files }}"

int('0420', 8) is 272, which is 0o420, which is -r---w----. The file becomes read-only for its owner and write-only for its group. Both the leading zero and the loop are present, the two interact, and nothing warns you.

This is what “works sometimes” means in practice: the unquoted form’s correctness depends on which code path the value happens to travel, and that path is decided by things — templating, concatenation, a filter, a vars file merge — that are not visible at the point of the mistake.

What to write instead

Configuration changeboth correct forms
# Quoted octal - always parsed as octal, from a string, by the module.
- name: Install the config
ansible.builtin.copy:
  src: app.conf
  dest: /etc/app/app.conf
  owner: root
  group: appsvc
  mode: '0640'

# Symbolic - readable, and impossible to get wrong by type conversion.
- name: Make the helper executable for its owner only
ansible.builtin.file:
  path: /usr/local/bin/app-helper
  mode: u=rwx,g=,o=

# In loop data, quote the mode at its source.
vars:
  config_files:
    - { path: /etc/app/app.conf,   mode: '0644' }
    - { path: /etc/app/secret.key, mode: '0600' }
    - { path: /usr/local/bin/run,  mode: '0755' }

Symbolic modes have been supported since Ansible 1.8 and accept the chmod syntax you already know — u+rwx, g-w, o=, a=r. They are the better choice in one specific situation: when you want to change some bits and leave the rest alone. An octal mode always sets every bit.

Verifying what landed

Whatever the playbook says, the file on the host is the fact.

Read-only / Safecheck the mode that actually landed
ansible -i inventories/prod webservers -m ansible.builtin.stat \
-a "path=/etc/app/app.conf" | grep -E '"mode"|"pw_name"|"gr_name"'

stat returns "mode": "0644" — a string, in the quoted octal form. That is not an accident of formatting: it is the representation the rest of Ansible expects, and a useful reminder of what your playbook should have contained.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A copy task specifies mode: 644 with no quotes. What permissions does the file end up with?

  2. Q2. Which of these forms reliably produce -rw-r--r--? Select all that apply.

  3. Q3. Because ansible-core 2.21 preserves integer types through a whole-value template, unquoted 0644 in loop data is now safe.

  4. Q4. Why does mode: 0644 produce the correct permissions despite being an integer?

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