Home DevOps Ansible Configuration Drift: Stop Servers Going Rogue with Idempotent Playbooks
Advanced 3 min · July 11, 2026

Ansible Configuration Drift: Stop Servers Going Rogue with Idempotent Playbooks

Ansible configuration drift detection and remediation.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Production
production tested
July 11, 2026
last updated
1,727
articles · all by Naren
Before you start⏱ 30 min
  • Working Ansible installation and inventory. Basic familiarity with Ansible playbooks and modules.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Use Ansible's --check mode to detect drift without applying changes, then run the playbook to remediate. For continuous compliance, schedule periodic runs via AWX/Tower or cron, and use assert modules to validate state. Always write idempotent playbooks — running them multiple times yields the same result.

✦ Definition~90s read
What is Configuration Drift and Compliance?

Configuration drift is the gradual, unplanned divergence of server configurations from their desired state. In Ansible, drift occurs when manual changes, failed runs, or time-based configs leave servers in inconsistent states. Compliance ensures every server matches a defined policy — no exceptions.

Imagine you manage 100 identical vending machines.
Plain-English First

Imagine you manage 100 identical vending machines. Over time, some get extra snacks stuffed in, others lose a spring, and a few have the wrong prices. That's drift. Compliance is checking every machine against a master blueprint and fixing any that deviate. Ansible is your wrench — it reads the blueprint and adjusts each machine back to spec, automatically.

Configuration drift is the silent killer of reliable infrastructure. I've seen a single manual chmod 777 on a config file cascade into a 3-hour outage because the monitoring agent stopped reporting. Drift is not a matter of if, but when — and it's almost always the result of someone 'just this once' SSHing in to fix something.

The problem is that most teams treat configuration as a one-time deployment. You push a playbook, servers look good, and you move on. But servers are living systems — patches, reboots, manual fixes, and time itself change them. Without continuous enforcement, your infrastructure becomes a snowflake farm where no two boxes are alike.

By the end of this article, you'll be able to detect configuration drift in your Ansible-managed servers, write playbooks that self-heal on every run, and build a compliance pipeline that alerts you the moment a server goes out of spec. No more mystery outages. No more 'it works on my machine'.

Why Drift Happens: The Three Enemies of Idempotence

Drift doesn't appear out of nowhere. It's caused by three things: manual intervention (someone SSHes in and tweaks a file), time-based configs (like SSL certs that expire), and failed automation runs (a playbook stops halfway, leaving partial state). Each of these breaks the idempotent promise — that running the same playbook twice yields the same result.

Without idempotence, you can't trust your automation. A playbook that works on Monday might break on Tuesday because a previous run left a file in an unexpected state. The fix is to write every task so it checks the current state before making changes. Ansible modules like copy, template, and lineinfile are idempotent by design — but only if you use them correctly. Never use shell or command to do something a module can do. That's where drift hides.

drift_example.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# io.thecodeforge — DevOps tutorial
---
- name: Detect and fix SSH config drift
  hosts: all
  tasks:
    - name: Ensure SSH config has correct permissions
      ansible.builtin.file:
        path: /etc/ssh/sshd_config
        owner: root
        group: root
        mode: '0600'
      # This is idempotent — file module checks current mode before changing

    - name: Enforce PermitRootLogin no
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PermitRootLogin'
        line: 'PermitRootLogin no'
      notify: restart sshd
      # lineinfile is idempotent — only changes if line doesn't match

  handlers:
    - name: restart sshd
      ansible.builtin.service:
        name: sshd
        state: restarted
Output
PLAY [all] **************************************************************
TASK [Ensure SSH config has correct permissions] ***********************
ok: [web01]
changed: [web02]
TASK [Enforce PermitRootLogin no] **************************************
ok: [web01]
ok: [web02]
RUNNING HANDLER [restart sshd] *****************************************
changed: [web02]
PLAY RECAP *************************************************************
web01 : ok=2 changed=0
web02 : ok=2 changed=1
Production Trap:
Never use shell or command to set file permissions. The file module is idempotent and reports changes correctly. shell always reports 'changed' — breaking idempotence and hiding drift.

Detecting Drift with Check Mode and Diff

Before you fix drift, you need to find it. Ansible's --check mode runs the playbook without making changes. It reports what would change. Combine it with --diff to see the exact lines that would be added, removed, or modified. This is your first line of defense against surprise changes.

But `--check` has a dirty secret: not all modules support it. The command and shell modules always report 'changed' in check mode because they can't predict the outcome. That's another reason to avoid them. Stick to modules that implement check mode properly — copy, template, file, lineinfile, service, package, user, group.

For continuous drift detection, run --check on a schedule (e.g., every 15 minutes via cron) and alert if any changes are reported. This catches manual tweaks before they cause issues.

check_mode.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# io.thecodeforge — DevOps tutorial
---
- name: Check for drift in NTP config
  hosts: all
  tasks:
    - name: Verify NTP config file
      ansible.builtin.template:
        src: ntp.conf.j2
        dest: /etc/ntp.conf
        owner: root
        group: root
        mode: '0644'
      check_mode: yes  # This task always runs in check mode, even if playbook doesn't use --check

    - name: Ensure NTP service is running
      ansible.builtin.service:
        name: ntpd
        state: started
        enabled: yes
      check_mode: yes
Output
PLAY [all] **************************************************************
TASK [Verify NTP config file] ******************************************
ok: [web01]
changed: [web02]
TASK [Ensure NTP service is running] ***********************************
ok: [web01]
ok: [web02]
PLAY RECAP *************************************************************
web01 : ok=2 changed=0
web02 : ok=1 changed=1
Senior Shortcut:
Run ansible-playbook site.yml --check --diff | grep -E '^---|^\+\+\+|^[+-]' to see only the actual diff lines. Pipe to less -R for color.

Continuous Compliance with Assert Modules

Check mode tells you what would change, but it doesn't enforce anything. For real compliance, you need tasks that validate the current state and fail if it doesn't match. Ansible's assert module is perfect for this. You can check file contents, package versions, user existence, service status — anything you can express as a condition.

Combine assert with fail to stop the playbook if a server is out of compliance. This is useful for audit trails: every run either passes (all asserts true) or fails with a clear message. You can also use assert in a separate compliance playbook that runs on a schedule and alerts on failure.

But be careful: assert only checks, it doesn't fix. For self-healing, use assert to detect drift and then run a remediation playbook. Or combine both in one playbook — check first, then fix.

compliance_check.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# io.thecodeforge — DevOps tutorial
---
- name: Compliance audit
  hosts: all
  tasks:
    - name: Check that /etc/hosts.deny is empty
      ansible.builtin.assert:
        that:
          - lookup('file', '/etc/hosts.deny') == ''
        fail_msg: "COMPLIANCE FAIL: /etc/hosts.deny is not empty on {{ inventory_hostname }}"
        success_msg: "OK: /etc/hosts.deny is empty"

    - name: Check that no users have UID 0 except root
      ansible.builtin.assert:
        that:
          - ansible_facts.getent_passwd | dict2items | selectattr('value.1', 'equalto', '0') | list | length == 1
        fail_msg: "COMPLIANCE FAIL: Multiple users with UID 0 on {{ inventory_hostname }}"

    - name: Ensure ntpd is running
      ansible.builtin.assert:
        that:
          - ansible_facts.services['ntpd'].state == 'running'
        fail_msg: "COMPLIANCE FAIL: ntpd not running on {{ inventory_hostname }}"
Output
PLAY [all] **************************************************************
TASK [Check that /etc/hosts.deny is empty] *****************************
fatal: [web01]: FAILED! => {
"assertion": "lookup('file', '/etc/hosts.deny') == ''",
"changed": false,
"msg": "COMPLIANCE FAIL: /etc/hosts.deny is not empty on web01"
}
TASK [Check that no users have UID 0 except root] **********************
ok: [web01] => {
"changed": false,
"msg": "All assertions passed"
}
TASK [Ensure ntpd is running] ******************************************
ok: [web01] => {
"changed": false,
"msg": "All assertions passed"
}
PLAY RECAP *************************************************************
web01 : ok=2 changed=0 failures=1
Interview Gold:
Q: How do you enforce compliance without breaking existing services? A: Use assert in check mode first, then run a separate remediation playbook. Never auto-fix in production without human approval.

Self-Healing Playbooks: Remediate Drift Automatically

Detection is useless without remediation. A self-healing playbook runs on a schedule (every hour, every day) and fixes any drift it finds. The key is idempotence: the playbook should only change what's wrong and leave everything else alone.

Design your playbooks in two phases: first, gather facts about the current state. Second, use modules that compare desired vs. actual state and make the minimum change. For example, lineinfile with regexp replaces only the matching line. template with validate checks syntax before overwriting.

But self-healing has a dark side: if the playbook itself has a bug, it can 'fix' servers into a broken state. Always run a new playbook in check mode on a canary server first. And version-control everything — you need to roll back a playbook change, not just a config change.

self_healing.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# io.thecodeforge — DevOps tutorial
---
- name: Self-healing playbook for web servers
  hosts: web_servers
  vars:
    desired_max_clients: 150
  tasks:
    - name: Gather current Apache config
      ansible.builtin.slurp:
        src: /etc/httpd/conf/httpd.conf
      register: httpd_config

    - name: Check MaxClients setting
      ansible.builtin.set_fact:
        current_max_clients: "{{ (httpd_config.content | b64decode).split('\n') | select('match', '^MaxClients') | first | regex_replace('.* (\\d+).*', '\\1') }}"

    - name: Fix MaxClients if wrong
      ansible.builtin.lineinfile:
        path: /etc/httpd/conf/httpd.conf
        regexp: '^MaxClients'
        line: 'MaxClients {{ desired_max_clients }}'
      when: current_max_clients | int != desired_max_clients
      notify: restart httpd

  handlers:
    - name: restart httpd
      ansible.builtin.service:
        name: httpd
        state: restarted
Output
PLAY [web_servers] ******************************************************
TASK [Gather current Apache config] ************************************
ok: [web01]
TASK [Check MaxClients setting] ****************************************
ok: [web01]
TASK [Fix MaxClients if wrong] *****************************************
changed: [web01]
RUNNING HANDLER [restart httpd] ****************************************
changed: [web01]
PLAY RECAP *************************************************************
web01 : ok=4 changed=2
Never Do This:
Don't use replace module with a regex that could match multiple lines. You'll corrupt the file. Always use lineinfile with a specific regexp that matches exactly one line.

Handling Time-Based Drift: Certificates and Passwords

Some drift is inevitable — SSL certificates expire, passwords rotate, API keys get revoked. These are time-based drifts that no amount of idempotence can prevent. The solution is to manage them as variables that change over time, and trigger playbook runs when they change.

For SSL certs, use Ansible's openssl_certificate module to generate self-signed certs or fetch from a CA. Set a notify handler to restart the web server only when the cert changes. For passwords, use password_hash filter to store hashed passwords in variables, and run the playbook whenever the variable changes (e.g., via a CI/CD pipeline).

But the real trick is monitoring: use openssl_certificate_info to check expiry dates and alert before they expire. Don't wait for the playbook to fail because the cert is invalid.

cert_renewal.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# io.thecodeforge — DevOps tutorial
---
- name: Check and renew SSL certificates
  hosts: web_servers
  tasks:
    - name: Get certificate info
      community.crypto.openssl_certificate_info:
        path: /etc/ssl/certs/example.com.pem
      register: cert_info

    - name: Fail if cert expires in less than 30 days
      ansible.builtin.assert:
        that:
          - cert_info.not_after > ansible_date_time.epoch + (30 * 24 * 3600)
        fail_msg: "SSL cert for example.com expires on {{ cert_info.not_after | ansible.builtin.from_epoch | ansible.builtin.to_date }}"

    - name: Renew cert if needed (using acme.sh or similar)
      ansible.builtin.command:
        cmd: /usr/local/bin/acme.sh --renew -d example.com
      when: cert_info.not_after < ansible_date_time.epoch + (30 * 24 * 3600)
      notify: restart nginx

  handlers:
    - name: restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
Output
PLAY [web_servers] ******************************************************
TASK [Get certificate info] ********************************************
ok: [web01]
TASK [Fail if cert expires in less than 30 days] ***********************
ok: [web01]
TASK [Renew cert if needed] ********************************************
skipping: [web01]
PLAY RECAP *************************************************************
web01 : ok=2 changed=0
Senior Shortcut:
Use ansible_date_time.epoch for timestamp comparisons. It's available on every host without extra facts.

When Not to Use Ansible for Drift Management

Ansible is great for configuration drift, but it's not a silver bullet. If you need real-time compliance enforcement (millisecond-level), Ansible's push model is too slow. Use a tool like OPA (Open Policy Agent) or a service mesh sidecar that enforces policies at the request level.

Also, Ansible doesn't handle stateful services well. If a database's configuration drifts, you can't just overwrite the config file and restart — you might lose data. For databases, use dedicated tools like pt-config-diff or mysql_config_editor.

Finally, if your infrastructure is ephemeral (containers that live minutes), drift is less of a problem because you rebuild from scratch. Focus on image compliance instead of runtime drift.

When to Skip This:
If you're running 100% immutable infrastructure (e.g., AWS Auto Scaling groups with golden AMIs), you don't need drift management. Your instances are cattle, not pets.
● Production incidentPOST-MORTEMseverity: high

The 4GB Container That Kept Dying

Symptom
A containerized payments service crashed every 72 hours with OOMKiller. Memory limit was 4GB, but the process never used more than 2GB.
Assumption
Memory leak in the application code.
Root cause
The container's /etc/security/limits.conf had been manually edited to set nofile to 65536, but the Ansible playbook set it to 1024. On restart, the container picked up the Ansible-managed file, reducing file descriptor limits. The app opened 5000+ connections, hit the limit, and the kernel OOM-killed it because of memory pressure from retries.
Fix
Set nofile to 65536 in the Ansible template and re-run the playbook. Added a wait_for task to ensure the service restarted cleanly. Also added a sysctl check to alert if fs.file-max was below 100000.
Key lesson
  • Never assume a config file is static.
  • If Ansible manages it, Ansible owns it.
  • Any manual change is a ticking time bomb.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Playbook runs but no changes, yet server is broken
Fix
1. Check if the broken config is managed by Ansible. 2. Run ansible -m setup hostname to gather facts. 3. Compare desired vs actual state manually. 4. Add an assert task to validate the specific setting.
Symptom · 02
Playbook fails with 'file not found' on some hosts
Fix
1. Verify the file path in the playbook. 2. Check if the file is created by another playbook that hasn't run yet. 3. Use stat module to check existence before using the file. 4. Order your plays correctly.
Symptom · 03
Compliance check passes but service still misbehaves
Fix
1. Check service logs (journalctl -u servicename). 2. Verify the service is using the correct config file (some apps have multiple config sources). 3. Check environment variables that override config. 4. Restart the service after compliance fix.
★ Configuration Drift and Compliance Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
`ansible-playbook site.yml --check` reports changes on a server that shouldn't have any
Immediate action
Check if someone manually edited the file
Commands
ansible hostname -m stat -a 'path=/etc/ssh/sshd_config'
ansible hostname -m fetch -a 'src=/etc/ssh/sshd_config dest=/tmp/ssh_config flat=yes'
Fix now
Run the playbook without --check to restore desired state
`ansible-playbook site.yml` fails with 'Permission denied' on a file+
Immediate action
Check file ownership and permissions
Commands
ansible hostname -m shell -a 'ls -la /etc/ssh/sshd_config'
ansible hostname -m stat -a 'path=/etc/ssh/sshd_config'
Fix now
Add owner: root group: root mode: '0600' to the file task
`ansible-playbook compliance.yml` fails assert on a package version+
Immediate action
Check installed package version
Commands
ansible hostname -m package_facts
ansible hostname -m debug -a 'var=ansible_facts.packages["nginx"][0].version'
Fix now
Update the desired version in vars or run a package upgrade playbook
`ansible-playbook site.yml` hangs on a task+
Immediate action
Check if the service is responsive
Commands
ansible hostname -m ping
ansible hostname -m shell -a 'systemctl is-active sshd'
Fix now
Add timeout: 30 to the task or increase SSH timeout in ansible.cfg
FeatureAnsible Drift DetectionOPA Real-Time Enforcement
LatencyMinutes (push-based)Milliseconds (sidecar)
State ManagementStateless (playbook runs)Stateful (policy cache)
Best ForVM/bare-metal configsKubernetes admission control
ComplexityLow (YAML)High (Rego language)
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
drift_example.yml- name: Detect and fix SSH config driftWhy Drift Happens
check_mode.yml- name: Check for drift in NTP configDetecting Drift with Check Mode and Diff
compliance_check.yml- name: Compliance auditContinuous Compliance with Assert Modules
self_healing.yml- name: Self-healing playbook for web serversSelf-Healing Playbooks
cert_renewal.yml- name: Check and renew SSL certificatesHandling Time-Based Drift

Key takeaways

1
Drift is when actual server state differs from desired state.
2
Detect drift with ansible-playbook --check --diff.
3
Correct drift automatically by running playbooks on a schedule.
4
Set up notifications for drift detection to maintain compliance.

Common mistakes to avoid

3 patterns
×

Only running drift detection manually

Fix
Schedule drift detection on a cron or AWX timer. Run --check --diff every 30-60 minutes for continuous compliance.
×

Not setting up drift notifications

Fix
Use Ansible to send Slack, email, or PagerDuty notifications when drift is detected. Silent drift becomes security debt.
×

Auto-remediating without audit trail

Fix
Log all drift detections and remediations. Use AWX or Ansible Runner to capture job output for compliance audits.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Ansible's `--check` mode work internally, and what are its limi...
Q02SENIOR
When would you choose Ansible over a pull-based tool like Chef or Puppet...
Q03SENIOR
What happens when an Ansible playbook fails halfway through — how do you...
Q04JUNIOR
What is idempotence and why is it critical for configuration management?
Q05SENIOR
A server's NTP config keeps reverting to default after every reboot. How...
Q06SENIOR
How would you design a compliance pipeline for 1000 servers using Ansibl...
Q01 of 06SENIOR

How does Ansible's `--check` mode work internally, and what are its limitations?

ANSWER
--check runs the playbook but sets a flag that modules can read. Most modules (file, copy, template) simulate changes without applying them. But command and shell always report 'changed' because they can't predict the outcome. Also, --check doesn't run handlers — so you won't see service restarts. Always combine with --diff to see what would change.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is configuration drift in Ansible?
02
How do I detect configuration drift with Ansible?
03
Can Ansible automatically correct drift?
04
What's the difference between drift detection in Ansible vs Puppet?
05
How do I set up drift notifications?
06
How does OpenSCAP integrate with Ansible for compliance?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
July 11, 2026
last updated
1,727
articles · all by Naren
🔥

That's Ansible. Mark it forged?

3 min read · try the examples if you haven't

Previous
Building Custom Ansible Collections
36 / 37 · Ansible
Next
Ansible for Kubernetes