Ansible Check Mode — Dry Run Playbooks Safely

Introduction

Check mode (--check) runs your playbook without making any changes — it predicts what would happen. Combined with --diff, it shows exactly what lines in files would change. This is essential for auditing, change management, and building confidence before applying changes to production.

Basic Usage

# Dry run a playbook
ansible-playbook site.yml --check

# Dry run with diff output
ansible-playbook site.yml --check --diff

# Diff only (shows changes but still applies them)
ansible-playbook site.yml --diff

How Check Mode Works

In check mode, Ansible:

  1. Connects to hosts normally
  2. Gathers facts normally
  3. Evaluates conditions normally
  4. Simulates each task — reports changed or ok without executing
  5. Returns what would change
---
- name: Deploy web server (safe with --check)
  hosts: webservers
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
      # --check: Reports "changed" if nginx is not installed
      # Does NOT actually install nginx

    - name: Deploy config
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
      # --check --diff: Shows exact line changes

Force Check Mode Per Task

    # Always run in check mode (even without --check flag)
    - name: Audit file permissions
      ansible.builtin.file:
        path: /etc/shadow
        mode: '0640'
      check_mode: true
      register: shadow_check

    - name: Alert if permissions wrong
      ansible.builtin.debug:
        msg: "WARNING: /etc/shadow permissions need fixing!"
      when: shadow_check.changed

Skip Check Mode Per Task

    # Always execute (even in --check mode)
    - name: Gather package facts (must run for later tasks)
      ansible.builtin.package_facts:
      check_mode: false

    # Tasks that depend on gathered data
    - name: Report installed nginx version
      ansible.builtin.debug:
        msg: "nginx {{ ansible_facts.packages['nginx'][0].version }}"
      when: "'nginx' in ansible_facts.packages"

Diff Mode

Diff mode shows line-by-line changes for file-modifying modules:

ansible-playbook site.yml --diff

Output example:

TASK [Deploy nginx config] *****
--- before: /etc/nginx/nginx.conf
+++ after: /home/user/.ansible/tmp/nginx.conf
@@ -1,5 +1,5 @@
 worker_processes auto;
-worker_connections 768;
+worker_connections 1024;
 keepalive_timeout 65;
-server_tokens on;
+server_tokens off;

Diff with Sensitive Data

    - name: Deploy config with secrets
      ansible.builtin.template:
        src: db.conf.j2
        dest: /etc/app/db.conf
      diff: false  # Suppress diff output (contains passwords)

Check Mode in CI/CD

# .github/workflows/ansible-check.yml
name: Ansible Dry Run
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Dry run playbook
        run: ansible-playbook site.yml --check --diff
        env:
          ANSIBLE_HOST_KEY_CHECKING: "false"

Handling Check Mode Limitations

Some modules don't support check mode — they skip entirely:

    - name: Run database migration
      ansible.builtin.command:
        cmd: /opt/app/migrate.sh
      # command/shell modules SKIP in check mode
      # They can't predict what a script would do

    - name: Run migration (check-aware)
      ansible.builtin.command:
        cmd: /opt/app/migrate.sh --dry-run
      when: ansible_check_mode
      changed_when: false

    - name: Run migration (real)
      ansible.builtin.command:
        cmd: /opt/app/migrate.sh
      when: not ansible_check_mode

The ansible_check_mode Variable

    - name: Different behavior in check mode
      ansible.builtin.debug:
        msg: "{{ 'DRY RUN — no changes' if ansible_check_mode else 'LIVE — applying changes' }}"

    - name: Skip destructive tasks in check mode
      ansible.builtin.file:
        path: /tmp/old-data
        state: absent
      when: not ansible_check_mode

Compliance Auditing Pattern

---
- name: Audit security compliance
  hosts: all
  become: true
  tasks:
    - name: Check SSH root login disabled
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PermitRootLogin'
        line: 'PermitRootLogin no'
      check_mode: true
      register: ssh_root

    - name: Check password auth disabled
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PasswordAuthentication'
        line: 'PasswordAuthentication no'
      check_mode: true
      register: ssh_password

    - name: Compliance report
      ansible.builtin.debug:
        msg: |
          Host: {{ inventory_hostname }}
          SSH Root Login: {{ 'FAIL' if ssh_root.changed else 'PASS' }}
          Password Auth: {{ 'FAIL' if ssh_password.changed else 'PASS' }}

Troubleshooting

IssueSolution
Task skipped in check modeModule doesn't support check; use check_mode: false
False "changed" in checkModule is check-mode aware but state differs
Dependent tasks failEarlier task didn't run; use check_mode: false for data-gathering
No diff outputModule doesn't support diff; works best with file/template/lineinfile

Best Practices

  1. Always --check --diff before production runs — review every change
  2. Use in CI/CD — catch playbook errors before merging
  3. Mark data-gathering tasks with check_mode: false so dependent tasks work
  4. Suppress diffs on secrets with diff: false
  5. Use for compliance auditing — check_mode: true + register to detect drift

Conclusion

Check mode is your safety net. Run --check --diff before every production deployment, use it in CI/CD pipelines, and build compliance audits with per-task check_mode: true. It turns Ansible from a configuration tool into an auditing tool — without changing a single line of your playbooks.