Education › DevOps › Stage 3: Run at scale

Configuration management with Ansible

Inventories, idempotent playbooks, roles — for everything that isn't a container.

Intermediate ~30 min read Module 12 of 17

Not everything runs in a container. Kubernetes nodes, database servers, build agents, network devices and the legacy estate every company has are still machines that need packages, users, config files and services kept in a known state. Terraform creates the machine; something has to configure what is inside it. Ansible does that over plain SSH with no agent to install, which is why it remains the most approachable configuration management tool.

After this module you can
  • Explain Ansible's agentless push model and where it fits beside Terraform and containers
  • Write inventories with groups and variables, and target hosts with patterns
  • Write idempotent playbooks using modules, variables, templates, handlers and conditionals
  • Organise reusable automation into roles and protect secrets with Ansible Vault
  • Test changes safely with check mode, diff mode, limits and linting

How Ansible works

Ansible runs on a control node, your laptop or a CI runner, and connects to managed nodes over SSH. For each task it copies a small Python program to the target, runs it, collects the JSON result and removes it. There is no daemon on the targets and no central server to maintain; the requirements are SSH access and Python on the managed node.

You describe the state you want, in YAML, using modules. A module knows how to check the current state and change it only if needed. "Package nginx is present" installs it the first time and does nothing the second. That property, idempotency, is the point of the tool, and it is what separates a playbook from a shell script. Each task reports ok (already correct), changed (it fixed something) or failed.

ToolManagesTypical question it answers
TerraformCloud resources: networks, VMs, databases, DNSDoes this server exist?
AnsibleWhat is inside a machine: packages, files, users, servicesIs this server configured correctly?
Docker / KubernetesApplications packaged as imagesIs this app running?
bash
python3 -m pip install --user ansible
ansible --version

# ad-hoc commands: one module, right now, no playbook
ansible all -i inventory.ini -m ansible.builtin.ping
ansible web -i inventory.ini -m ansible.builtin.command -a "uptime"
ansible db -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_distribution*"
CONTROL NODEMANAGED NODESwhowhatSSH: copy module, run itJSON: ok, changed, failedInventorygroups + varsansible-playbooksite.ymlRoles, vaultreusable, encryptedweb1SSH + Python onlyweb2SSH + Python onlydb1SSH + Python only
Ansible's agentless push model: the control node reads the inventory and the playbook, connects to each managed node over SSH, runs a module there, and collects a JSON result. Nothing is installed on the targets beyond SSH and Python.
Note

The ping module is not ICMP. It proves that Ansible can log in over SSH and run Python on the target, which is the real prerequisite for everything else.

Inventory

The inventory lists the hosts Ansible may touch and arranges them into groups. Groups are how you say "all web servers" or "everything in production", and variables can be attached to a group or a single host.

inventory/production.yml
yaml
all:
  children:
    web:
      hosts:
        web1.example.com:
        web2.example.com:
      vars:
        http_port: 8080
    db:
      hosts:
        db1.example.com:
          postgres_role: primary
  vars:
    ansible_user: deploy
    ansible_ssh_private_key_file: ~/.ssh/deploy_ed25519

Keep one inventory per environment (inventory/staging.yml, inventory/production.yml) so that choosing production is always an explicit -i flag and never an accident. Variables that belong to groups are tidier in files than inline: Ansible automatically loads group_vars/web.yml and host_vars/db1.example.com.yml found next to the inventory or the playbook.

In the cloud, servers come and go, so a static file goes stale. Dynamic inventory plugins query the provider's API and build groups from tags, which ties in neatly with the tagging discipline you will meet in the FinOps module.

bash
ansible-inventory -i inventory/production.yml --graph      # groups and hosts as a tree
ansible-inventory -i inventory/production.yml --host web1.example.com   # merged variables

Playbooks

A playbook is a list of plays. Each play maps a group of hosts to an ordered list of tasks, and each task calls one module. Ansible runs a task on every host in the play before moving to the next task.

site.yml
yaml
- name: Configure web servers
  hosts: web
  become: true
  vars:
    app_user: shop

  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Create the application user
      ansible.builtin.user:
        name: "{{ app_user }}"
        system: true
        shell: /usr/sbin/nologin

    - name: Deploy the nginx site config
      ansible.builtin.template:
        src: templates/shop.conf.j2
        dest: /etc/nginx/sites-available/shop.conf
        owner: root
        group: root
        mode: "0644"
      notify: Reload nginx

    - name: Make sure nginx is running and enabled at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
  • become: true escalates privileges with sudo, the same principle of least privilege as in the Linux module: connect as an ordinary user and escalate only where needed.
  • Modules are referred to by their fully qualified collection name, such as ansible.builtin.apt, which avoids ambiguity when several collections are installed.
  • A handler runs only if a task that notifies it reported changed, and only once, at the end of the play. That is how you reload a service when its config changed, and not otherwise.
  • state: started plus enabled: true is the "start versus enable" distinction from the Linux module, expressed declaratively.
  • Quote file modes ("0644"). Unquoted, YAML reads the number as decimal and you get the wrong permissions.
bash
ansible-playbook -i inventory/staging.yml site.yml
ansible-playbook -i inventory/production.yml site.yml --limit web1.example.com
ansible-playbook -i inventory/production.yml site.yml --tags nginx

Variables, facts, templates and conditionals

Before the first task, Ansible gathers facts about each host: operating system, addresses, memory, CPU count. Facts and your own variables are available in tasks and in Jinja2 templates, which is how one template produces the right config file for each host.

templates/shop.conf.j2
jinja2
# Managed by Ansible. Manual changes will be overwritten.
upstream shop_backend {
{% for host in groups['web'] %}
    server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:{{ http_port }};
{% endfor %}
}

server {
    listen 80;
    server_name {{ inventory_hostname }};
    # {{ ansible_facts['distribution'] }} host with {{ ansible_facts['processor_vcpus'] }} vCPUs
    location / {
        proxy_pass http://shop_backend;
    }
}
yaml
- name: Install the firewall package on Debian-family hosts only
  ansible.builtin.apt:
    name: ufw
    state: present
  when: ansible_facts['os_family'] == "Debian"

- name: Create several directories
  ansible.builtin.file:
    path: "/srv/shop/{{ item }}"
    state: directory
    owner: "{{ app_user }}"
    mode: "0750"
  loop:
    - releases
    - shared
    - logs

- name: Check the running application version
  ansible.builtin.command: /srv/shop/bin/shop --version
  register: shop_version
  changed_when: false          # a read-only command never counts as a change
  failed_when: shop_version.rc not in [0, 3]

Variables can be defined in more than twenty places, with a defined precedence. You do not need the whole list. Remember the ends: role defaults are the weakest, so they are where a role puts overridable settings, and extra vars passed with -e on the command line beat everything.

Watch out

command and shell run arbitrary commands and cannot know whether anything changed, so they report changed every time and break idempotency. Reach for a purpose-built module first. When you must use them, add creates: or changed_when: so that a second run is a no-op.

Roles and secrets

A role packages tasks, handlers, templates, files and default variables in a standard directory layout, so that the same automation can be reused across playbooks and shared through Ansible Galaxy.

text
roles/nginx/
  defaults/main.yml     # overridable settings (lowest precedence)
  vars/main.yml         # internal constants (high precedence)
  tasks/main.yml        # entry point for the role's tasks
  handlers/main.yml
  templates/            # Jinja2 templates
  files/                # static files
  meta/main.yml         # dependencies on other roles
site.yml using roles
yaml
- name: Web tier
  hosts: web
  become: true
  roles:
    - common
    - role: nginx
      vars:
        nginx_worker_connections: 4096

Pin third-party roles and collections in a requirements.yml and install them with ansible-galaxy install -r requirements.yml, for the same reproducibility reasons as any other dependency.

Ansible Vault encrypts variable files so that they can live in Git. The decryption password is supplied at run time, from a prompt locally or from a secret in CI.

bash
ansible-vault create group_vars/production/vault.yml
ansible-vault edit group_vars/production/vault.yml
ansible-vault view group_vars/production/vault.yml
ansible-playbook -i inventory/production.yml site.yml --ask-vault-pass
ansible-playbook -i inventory/production.yml site.yml --vault-password-file ~/.vault_pass

Add no_log: true to any task that handles a secret, or its value can appear in the output and in CI logs.

Running changes safely

bash
ansible-playbook site.yml --syntax-check
ansible-lint site.yml                                   # separate tool; catches bad practice
ansible-playbook -i inventory/production.yml site.yml --check --diff   # dry run with file diffs
ansible-playbook -i inventory/production.yml site.yml --limit 'web[0]' # one canary host first
ansible-playbook -i inventory/production.yml site.yml --list-hosts     # who would be touched?

--check asks modules to report what they would change without doing it, and --diff shows the file changes line by line. Together they are Ansible's equivalent of terraform plan. They are not perfect, because a task that depends on the result of an earlier task may misreport in check mode, but they catch most surprises.

For fleets, control the blast radius inside the play: serial updates hosts in batches instead of all at once, and max_fail_percentage stops the rollout when too many hosts fail.

yaml
- name: Rolling update of the web tier
  hosts: web
  become: true
  serial: "25%"
  max_fail_percentage: 10
  roles:
    - shop_app

The idempotency test for any playbook is simple: run it twice. The second run must report changed=0. If it does not, a task is not describing state, and it will keep causing needless restarts and noisy diffs.

Hands-on practice

Configure two servers and prove it is idempotent

  1. Create two small Linux VMs you can reach over SSH with a key: cloud free tier, Multipass, or Vagrant. Install Ansible on your own machine.
  2. Write a YAML inventory with a web group containing both hosts, and confirm connectivity with ansible web -i inventory.yml -m ansible.builtin.ping.
  3. Write a playbook that installs nginx, deploys an index.html from a Jinja2 template that prints the hostname and an OS fact, and ensures the service is started and enabled.
  4. Add a handler that reloads nginx, notified only by the config template task. Run the playbook twice and confirm the second run shows changed=0 and the handler did not fire.
  5. Change the template, then run with --check --diff to preview the change, then apply it to a single host with --limit before the rest.
  6. Move the tasks into a roles/nginx role with a default variable in defaults/main.yml, and override it from the playbook.
  7. Put a fake API key in a vault-encrypted vars file, use it in a template, add no_log: true to the task, and run with --ask-vault-pass. Run ansible-lint and fix what it reports.
Cheat sheet

Configuration management with Ansible — at a glance

Main things to focus on

  • Agentless: a control node pushes over SSH; targets need only SSH and Python.
  • Idempotency is the whole point. Declare state with modules; a second run must show changed=0.
  • Inventory defines who, groups define targeting, one inventory file per environment.
  • Handlers run once, at the end of the play, and only when a notifying task changed something.
  • Avoid command and shell; if unavoidable, add creates: or changed_when:.
  • Role defaults/ are the weakest variables; -e extra vars are the strongest.
  • Preview with --check --diff, narrow with --limit, batch with serial.
  • Secrets go in Ansible Vault, and tasks that touch them get no_log: true.

Command line

ansible GROUP -i INV -m ansible.builtin.pingTest SSH login and Python on the targets
ansible GROUP -i INV -m MODULE -a "ARGS"Ad-hoc: run one module now
ansible-playbook -i INV site.ymlRun a playbook
--check --diffDry run, showing file changes
--limit HOST_OR_GROUPRestrict to a subset of the play's hosts
--tags NAME / --skip-tags NAMERun or skip tagged tasks
-e "key=value"Extra variables; highest precedence
--list-hosts / --list-tasksShow what would be targeted or run
-v / -vvvMore output; -vvv shows SSH details

Everyday modules

ansible.builtin.package / apt / dnfPackages: name, state: present|absent|latest
ansible.builtin.servicestate: started|stopped|restarted|reloaded, enabled: true
ansible.builtin.copyStatic file to the target: src, dest, owner, mode
ansible.builtin.templateRender a Jinja2 template; validate: checks it before replacing
ansible.builtin.fileDirectories, links, permissions: state: directory|link|absent
ansible.builtin.lineinfileEnsure one line is present in a file
ansible.builtin.user / groupAccounts and groups
ansible.builtin.gitCheck out a repository at a version
ansible.builtin.uriHTTP request, useful for health checks
ansible.builtin.debugPrint a variable: var: NAME

Task keywords

become: trueRun with sudo
when: CONDITIONRun only if true; no {{ }} around the expression
loop: [a, b, c]Repeat the task; the current element is item
register: NAMESave the task's result in a variable
notify: HANDLER NAMEQueue a handler if this task reports changed
changed_when: falseMark a read-only command as never changing
failed_when: CONDITIONDefine what failure means for this task
no_log: trueHide the task's arguments and output
tags: [NAME]Label tasks for selective runs

Roles, Galaxy and Vault

ansible-galaxy role init NAMEScaffold the standard role layout
ansible-galaxy install -r requirements.ymlInstall pinned roles and collections
ansible-vault create|edit|view FILEWork with an encrypted variables file
ansible-vault encrypt_string 'VALUE' --name 'KEY'Encrypt one value to paste into YAML
--ask-vault-pass / --vault-password-file FILESupply the vault password at run time
ansible-lintLint playbooks and roles for mistakes and bad practice

Common pitfalls

  • Replacing modules with shell commands, so that every run reports changes and restarts services for nothing.
  • Writing mode: 0644 unquoted, which YAML reads as a decimal number and produces wrong permissions.
  • Restarting a service in a normal task instead of a handler, so it restarts on every run.
  • Running against production because it was the default inventory instead of an explicit -i.
  • Committing plaintext secrets in group_vars, or leaking them into logs without no_log.
  • Updating every host at once, with no serial batches and no canary host.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →