Infrastructure as code · Intermediate

Idempotent server configuration with Ansible

3 min read

Why it matters

Terraform creates infrastructure; something still has to configure what runs on virtual machines: directories, config files, packages, services. Ansible is the most common tool for this and appeared in about a quarter of the DevOps job descriptions we read. The core idea interviewers check is idempotency: running the same playbook again should change nothing.

The concept

  • An inventory lists hosts and groups.

  • A playbook maps groups of hosts to tasks, each calling a module (file, copy, template, apt, systemd, lineinfile).

  • Modules are idempotent: they check the current state and act only if it differs, reporting ok or changed.

  • Handlers run only when notified by a task that changed, for example "restart the app if its config changed".

  • Ansible is agentless: it connects over SSH (or locally) and runs modules.

Worked example: an application host baseline

inventory.ini
[web]
localhost ansible_connection=local
site.yml
- name: Baseline for application hosts
  hosts: web
  become: false
  vars:
    app_dir: /tmp/ansible-demo/app
  tasks:
    - name: Create the application directory
      ansible.builtin.file:
        path: "{{ app_dir }}"
        state: directory
        mode: "0750"

    - name: Write the application config
      ansible.builtin.copy:
        dest: "{{ app_dir }}/app.env"
        mode: "0640"
        content: |
          APP_ENV=production
          LOG_LEVEL=info
          PORT=8000
      notify: Restart app

    - name: Ensure log rotation keeps 14 days
      ansible.builtin.lineinfile:
        path: "{{ app_dir }}/logrotate.conf"
        create: true
        mode: "0644"
        regexp: '^rotate '
        line: 'rotate 14'

  handlers:
    - name: Restart app
      ansible.builtin.debug:
        msg: "Config changed: the app would be restarted here (systemd service on a real host)"
run.sh
python -m venv .venv && source .venv/bin/activate && pip install ansible-core
ansible-playbook -i inventory.ini site.yml     # first run
ansible-playbook -i inventory.ini site.yml     # second run
output
first run:   localhost : ok=5    changed=3    unreachable=0    failed=0
second run:  localhost : ok=4    changed=0    unreachable=0    failed=0

The first run creates the directory and both files and runs the handler. The second run finds everything already correct: nothing changes and the handler does not run, so the app is not restarted needlessly. On a real server you would use ansible.builtin.template with a Jinja2 file, become: true to install packages, and ansible.builtin.systemd in the handler.

Common mistakes

  • Using shell or command for everything. They always report "changed" and are not idempotent. Use the purpose-built module.

  • Secrets in plain text in playbooks. Use Ansible Vault or a secrets manager lookup.

  • Restarting services in tasks instead of handlers. Every run then restarts production.

  • Snowflake servers. If you fix a server by hand, the next run may undo it, or worse, not know about it. Change the playbook.

Interview angle

"What does idempotent mean in configuration management, and why does it matter?" Show it with the two runs above. Then: "Terraform or Ansible?" Terraform for provisioning cloud resources with state; Ansible for configuring what runs inside machines; many teams use both, and containers reduce the need for the second.

Practice task

  1. Change LOG_LEVEL to debug and confirm only that task and the handler run.

  2. Replace the copy task with a template task and a templates/app.env.j2 file using variables.

  3. Add a task with ansible.builtin.command and make it idempotent with creates:.

Further reading