Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Ansible Copy Module: The Three Mistakes Everyone Makes First

Sean

Platform Writer

Aug 20, 2026
9 min read

ansible.builtin.copy pushes a file from the control node to the target host. Almost every confusing failure with it comes from one of three things: assuming it copies on the remote machine, using it where template belongs, or fighting it over permissions it was never told about.

Ansible Copy Module: The Three Mistakes Everyone Makes First

The module has about twenty parameters and you will use four of them. The documentation is thorough and still manages to bury the parts that actually cause outages, because those parts are behavioural rather than syntactic. This is the working developer’s version: what it does, the three ways it surprises you, and when to reach for something else.

Table of contents

The default assumes the file is on the control node

This is the first surprise, and it is a good one to get out of the way early. By default copy looks for src on the machine running the playbook, not on the host you are configuring.

- name: Push a config file from the repo to the server
  ansible.builtin.copy:
    src: files/nginx-site.conf
    dest: /etc/nginx/sites-available/app.conf
    owner: root
    group: root
    mode: '0644'

That src is relative to the playbook’s files/ directory on your laptop or CI runner. If you meant copy this file that already exists on the server to another location on the server, you need remote_src:

- name: Keep a backup of the live config before replacing it
  ansible.builtin.copy:
    src: /etc/nginx/sites-available/app.conf
    dest: /etc/nginx/sites-available/app.conf.bak
    remote_src: true

Without that flag, Ansible goes looking on the control node for a path called /etc/nginx/sites-available/app.conf, and either fails with a not-found error or — much worse — finds your control node’s own copy of that file and pushes it to the server. The second outcome is the one that ruins an afternoon, because it succeeds.

remote_src also gained recursive copying later than the rest of the module, and it only honours mode: preserve from a certain version onward. If you are on an older ansible-core and a recursive remote copy behaves oddly, that is usually why.

If the file contains a variable, you want template, not copy

The content parameter lets you write a file inline, and it is genuinely useful for one-line files. It is also the single most common misuse of the module:

# Works, but fragile
- name: Write a marker file
  ansible.builtin.copy:
    content: '# Managed by Ansible, do not edit'
    dest: /etc/myapp/README

# Do not do this
- name: Write the app config
  ansible.builtin.copy:
    content: "database_url={{ db_url }}\nworkers={{ worker_count }}\n"
    dest: /etc/myapp/app.conf

The module’s own documentation says it plainly: using a variable with content produces unpredictable results. Multi-line strings, YAML folding, quoting and Jinja interpolation interact in ways that are hard to reason about and harder to review in a pull request.

The moment a file has a variable in it, it is a template:

- name: Render the app config
  ansible.builtin.template:
    src: app.conf.j2
    dest: /etc/myapp/app.conf
    owner: myapp
    group: myapp
    mode: '0640'
    validate: /usr/local/bin/myapp --check-config %s
  notify: restart myapp

You get the same parameters, plus the file lives in templates/ where a reviewer can read it as a file instead of as an escaped string inside YAML. The validate parameter is worth adopting as a habit: Ansible renders to a temporary path, runs your checker against it, and only moves it into place if the check passes. A config file that fails validation never reaches the server, so the service restart in the handler never happens against a broken file.

The mode parameter and the octal-number trap

Permissions in Ansible are where YAML’s type system bites. File modes are octal. YAML will happily parse 0644 as a number, and depending on the parser version you may end up with decimal 644, which is a permission set nobody wanted.

The rule is simple and absolute: always quote the mode, or use symbolic notation.

mode: '0644'      # correct -- quoted string
mode: u=rw,g=r,o=r  # correct -- symbolic
mode: 0644        # risky -- depends on parser
mode: 644         # wrong -- decimal 644

There is a second, quieter behaviour worth knowing. If you omit mode entirely and the destination file does not exist, the new file gets whatever the target system’s umask produces. If the destination does exist, it keeps its current mode. So the same task produces different permissions depending on whether it is a first run or a re-run, which is exactly the kind of thing that shows up as a mysterious permissions bug on a rebuilt server months later.

This was serious enough to earn a CVE. Specify mode on every file task, even when the default looks right.

For a recursive directory copy, mode applies to files and directory_mode applies to newly created directories. Existing directories are left alone, which surprises people who expect a recursive copy to normalise everything underneath.

Idempotency, checksums, and the changed-every-run problem

copy is idempotent: it checksums the source and the destination and only transfers when they differ. A task that reports changed on every run is telling you something is different every run, and it is worth finding out what.

  • The source is generated. If something in your pipeline regenerates the file with a timestamp or a build ID inside it, the checksum changes each time. Fix the generator, not the task.
  • A trailing newline mismatch. Editors and here-documents disagree about final newlines constantly, and it is enough to change the checksum.
  • Ownership or mode drift. The content matches, but something on the server keeps resetting the owner. copy reports changed because it is re-applying the metadata.
  • You used content with a variable. See the previous section. Rendering differences count as changes.

Chasing this matters more than it looks, because a permanently-changed task poisons your handlers. If notify: restart myapp fires on every playbook run, you are restarting production every time anyone runs Ansible for any reason — including the run that was only meant to check something.

Two flags help while debugging. --check runs without making changes and reports what would happen; --diff shows the actual content difference for text files. Together they answer the question in one run:

ansible-playbook site.yml --check --diff --limit web01

And backup: true on the task keeps a timestamped copy of whatever you replaced, which costs nothing and has saved more than one rollback.

When copy is the wrong tool entirely

The module’s own notes contain a warning that is easy to skim past: recursive copy does not scale to more than a few hundred files. It is not a bug, it is the design — each file is checksummed and transferred individually over the connection.

If you are pushing a built frontend, a node_modules tree, or anything with thousands of small files, copy will take minutes where the right tool takes seconds.

  • Many files: ansible.posix.synchronize, which wraps rsync and transfers only differences.
  • An archive that should be unpacked: ansible.builtin.unarchive, which can fetch and extract in one task.
  • A file from a URL: ansible.builtin.get_url, with a checksum so you know you got the right one.
  • Anything with a variable in it: ansible.builtin.template.
  • A whole application: none of the above. See below.

That last one is the honest answer for a lot of playbooks. A great deal of Ansible in the wild is a hand-rolled deployment pipeline — copy the build output, template a config, restart the unit, hope the health check passes — reimplemented once per company, maintained by whoever wrote it.

How this fits the rest of the stack

Ansible is excellent at making a server look a particular way. It is a much heavier tool for the narrower job of getting one application from a repository onto a running host, and that is what a large share of copy-and-restart playbooks are actually doing.

On RunxBuild that job is the platform’s: push to GitHub, the service builds, the build log and the runtime logs land in the same place, environment variables and custom domains are dashboard settings rather than templated files, and a bad release rolls back to the previous deploy. There is no config file to checksum because there is no server to keep in shape. If you want to see what that costs next to a fleet you configure yourself, the RunxBuild hosting calculator breaks the service, the managed database, the storage and the bandwidth out as separate line items.

Useful related references:

FAQ

What is the difference between the Ansible copy and template modules?

copy transfers a file byte for byte. template renders it through Jinja2 first, substituting variables and evaluating loops and conditionals. They share nearly all the same parameters. The rule of thumb: if the file contains a variable, use template — using copy with the content parameter and a variable is documented as producing unpredictable results.

Why does the Ansible copy module say it cannot find my file?

Almost always because src points at a path on the remote host while copy is looking on the control node. Add remote_src: true for a server-to-server copy. If the path is meant to be local, remember it resolves relative to the playbook’s files/ directory, not your shell’s working directory.

How do I set file permissions correctly with the copy module?

Quote the octal value — mode: '0644' — or use symbolic notation like u=rw,g=r,o=r. Unquoted, YAML may parse it as a decimal number and apply a permission set you did not intend. Always set mode explicitly, because omitting it means new files inherit the target’s umask and existing files keep whatever they already had.

Why does my Ansible copy task report changed on every run?

Something differs each time: a regenerated source file with a timestamp inside, a trailing-newline mismatch, ownership drift being re-applied, or a variable rendered through the content parameter. Run with --check --diff to see exactly what the module thinks is different. Fix it, because a permanently-changed task fires its handlers on every run.

Can the copy module handle large directories?

Not well. The module’s own documentation notes that recursive copying does not scale beyond a few hundred files, because each one is checksummed and transferred separately. For large trees use ansible.posix.synchronize, which wraps rsync and only sends differences, or unarchive if the content can travel as an archive.

#ansible copy#ansible#configuration management#devops#automation