Introduction

The ansible.builtin.file module with state: absent removes files and directories on remote hosts. It's idempotent — running it again on an already-deleted path doesn't fail or report "changed". This guide covers single files, directories, wildcards, conditional deletion, and cleanup patterns.

Quick Reference

# Delete a file
- ansible.builtin.file:
    path: /tmp/old-config.conf
    state: absent

# Delete a directory recursively
- ansible.builtin.file:
    path: /opt/old-release
    state: absent

# Delete multiple files
- ansible.builtin.file:
    path: "{{ item }}"
    state: absent
  loop:
    - /tmp/file1.txt
    - /tmp/file2.txt
    - /var/log/old-app.log

Delete Patterns (Wildcards)

# Find and delete files matching a pattern
- name: Find old log files
  ansible.builtin.find:
    paths: /var/log/myapp
    patterns: "*.log.gz"
    age: "30d"  # Older than 30 days
  register: old_logs

- name: Delete old logs
  ansible.builtin.file:
    path: "{{ item.path }}"
    state: absent
  loop: "{{ old_logs.files }}"

Delete All Files in Directory (Keep Directory)

- name: Find all files in temp dir
  ansible.builtin.find:
    paths: /tmp/build-artifacts
    file_type: any
  register: artifacts

- name: Remove all artifacts
  ansible.builtin.file:
    path: "{{ item.path }}"
    state: absent
  loop: "{{ artifacts.files }}"

Conditional Deletion

# Delete only if file exists (always safe — state: absent is already idempotent)
- name: Remove config if migration complete
  ansible.builtin.file:
    path: /etc/myapp/old-config.yml
    state: absent
  when: migration_complete | default(false)

# Delete based on file age
- name: Find stale PID files
  ansible.builtin.find:
    paths: /var/run
    patterns: "myapp-*.pid"
    age: "1h"
  register: stale_pids

- name: Remove stale PIDs
  ansible.builtin.file:
    path: "{{ item.path }}"
    state: absent
  loop: "{{ stale_pids.files }}"
  when: stale_pids.matched > 0

Common Cleanup Patterns

Post-Deployment Cleanup

- name: Clean up deployment artifacts
  ansible.builtin.file:
    path: "{{ item }}"
    state: absent
  loop:
    - /tmp/deploy-package.tar.gz
    - /tmp/deploy-checksum.sha256
    - /opt/app/previous-release

- name: Remove old releases (keep last 3)
  block:
    - name: List all releases
      ansible.builtin.find:
        paths: /opt/app/releases
        file_type: directory
      register: releases

    - name: Sort and identify old releases
      ansible.builtin.set_fact:
        old_releases: "{{ releases.files | sort(attribute='mtime') | map(attribute='path') | list | reverse | list | slice(3) | last | default([]) }}"

    - name: Remove old releases
      ansible.builtin.file:
        path: "{{ item }}"
        state: absent
      loop: "{{ old_releases }}"

Clean Temp Files by Age

- name: Find temp files older than 7 days
  ansible.builtin.find:
    paths: /tmp
    age: "7d"
    recurse: false
    file_type: file
  register: old_temp

- name: Remove old temp files
  ansible.builtin.file:
    path: "{{ item.path }}"
    state: absent
  loop: "{{ old_temp.files }}"
  loop_control:
    label: "{{ item.path }}"

Remove Package and Config

- name: Remove application completely
  block:
    - name: Stop service
      ansible.builtin.service:
        name: myapp
        state: stopped
      ignore_errors: true

    - name: Remove package
      ansible.builtin.apt:
        name: myapp
        state: absent
        purge: true

    - name: Remove config and data directories
      ansible.builtin.file:
        path: "{{ item }}"
        state: absent
      loop:
        - /etc/myapp
        - /var/lib/myapp
        - /var/log/myapp
        - /opt/myapp
# Remove symlink (not target)
- name: Remove symlink
  ansible.builtin.file:
    path: /usr/local/bin/myapp
    state: absent
  # Only removes the link, not what it points to

Safe Deletion with Backup

# Move to trash instead of delete
- name: Move to trash before deleting
  ansible.builtin.command:
    cmd: "mv {{ item }} /tmp/trash/{{ item | basename }}.{{ ansible_date_time.epoch }}"
    removes: "{{ item }}"
  loop:
    - /etc/myapp/old-config.yml

# Or use copy + delete pattern
- name: Backup before delete
  ansible.builtin.fetch:
    src: /etc/myapp/config.old
    dest: ./backups/
  ignore_errors: true

- name: Then delete
  ansible.builtin.file:
    path: /etc/myapp/config.old
    state: absent

Troubleshooting

IssueFix
Permission deniedUse become: true
Directory not emptystate: absent removes recursively — this always works
Symlink target deleted insteadThis doesn't happen — file: absent removes the path itself
Need to delete by patternUse find module first, then loop over results
Task shows "ok" not "changed"File already absent — this is correct idempotent behavior

file (absent) vs shell rm

Featurefile: absentshell: rm
Idempotent✅❌ (fails if missing without -f)
Check mode✅❌
Diff mode✅❌
Wildcards❌ (use find)✅ (rm *.log)
Recursive✅Needs -rf

Best Practices

  1. Use state: absent — idempotent, safe, check-mode compatible
  2. Use find + loop for patterns — don't shell out to rm
  3. Backup important files first — fetch before deleting
  4. Use loop_control: label — for readable output with many files
  5. Add ignore_errors only when the file truly might not exist AND you don't care
  6. Clean up in handlers — delete temp files after deployment succeeds

Conclusion

Use ansible.builtin.file with state: absent to delete files and directories. It's idempotent and recursive by default. For pattern-based deletion, use find to locate files then loop with file: absent. Always prefer this over shell: rm for idempotency and check-mode support. Back up critical files with fetch before deletion.