Skip to main content
DevOps Insightsguides & answers

Infrastructure · beginner

Ansible for DevOps: Automating Server Configuration

Short answer

Ansible runs tasks on servers over SSH, with no agent installed on them — only Python, which most Linux servers already have. Its one real idea is idempotence: a task describes the state you want, so running the playbook twice changes nothing the second time. Almost every Ansible problem is somebody using a module that does not hold that property, usually shell or command.

N
Neeraj Jha
Updated 7 min read157 views
On this page · 12 sections
Diagram: an Ansible control node running a playbook over SSH against three web hosts from its inventory.

Who this is for

You have more than one server and you are configuring them by hand. You do not need to know Python, and you do not need to install an agent anywhere.

Why agentless matters

Ansible connects over SSH and runs Python on the far side. There is no daemon to install, no port to open, no certificate to rotate, and nothing to upgrade on two hundred machines. The cost is that a run is only as fast as SSH to the slowest host, and that your control machine needs network reach to everything.

For a fleet that already has a package manager and SSH, that trade is usually the right way round.

Inventory

ini
# inventory.ini
[web]
web01.example.com
web02.example.com

[db]
db01.example.com ansible_user=postgres

[production:children]
web
db

[web:vars]
nginx_worker_processes=4

Groups are the unit you will actually use — --limit web on a playbook run, and when: inventory_hostname in groups['db'] inside one. Get the grouping right early; renaming groups later means editing every playbook that mentions them.

bash
ansible all -i inventory.ini -m ping     # does it connect at all
ansible web -i inventory.ini -a "uptime" # one ad-hoc command

That ping is not ICMP — it checks SSH, Python, and your credentials end to end. Run it before debugging anything else.

A playbook

yaml
---
- name: Configure web servers
  hosts: web
  become: true

  vars:
    app_port: 8080

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

    - name: Write the site configuration
      ansible.builtin.template:
        src: nginx-site.conf.j2
        dest: /etc/nginx/sites-available/app.conf
        owner: root
        group: root
        mode: "0644"
      notify: Reload nginx

    - name: Enable the site
      ansible.builtin.file:
        src: /etc/nginx/sites-available/app.conf
        dest: /etc/nginx/sites-enabled/app.conf
        state: link
      notify: Reload nginx

  handlers:
    - name: Check nginx configuration
      ansible.builtin.command: nginx -t
      changed_when: false
      listen: Reload nginx

    - name: Apply the reload
      ansible.builtin.service:
        name: nginx
        state: reloaded
      listen: Reload nginx

Three things there are doing real work.

notify and handlers. The handler runs once at the end of the play, and only if a task actually changed something. Writing the same config twice does not reload nginx twice — and more importantly, a run that changes nothing restarts nothing.

A config test before the reload. Both handlers listen on Reload nginx, and handlers run in the order they are defined, so nginx -t checks the whole configuration, new site included, and a failure stops the play on that host before anything is reloaded. The new file is already on disk by then, so fix it before anything restarts nginx. The tempting shortcut, validate: "nginx -t -c %s" on the template task, fails every time here: -c expects a complete nginx.conf, and a site file is a bare server {} block. validate on template and copy is the right tool for files that stand alone (validate: /usr/sbin/visudo -cf %s on a sudoers file is the classic case), and it is consistently underused. Without any check, the reload is refused, nginx keeps serving the old configuration, and the damage waits for the next restart.

mode: "0644" quoted. Unquoted, YAML reads a mode as a number: 0644 usually survives as the octal you meant, but 644 becomes decimal 644 and you get permissions nobody intended. Always quote file modes.

Idempotence, and how people lose it

Modules like apt, file, template and service check the current state first and report changed only when they altered something. command and shell cannot — Ansible has no idea what your command does, so it runs every time and reports changed every time.

yaml
# Runs on every single play. "changed" becomes meaningless.
- name: Extract the release
  ansible.builtin.shell: tar -xzf /tmp/release.tar.gz -C /opt/app

# Runs once, because the target already exists afterwards.
- name: Extract the release
  ansible.builtin.unarchive:
    src: /tmp/release.tar.gz
    dest: /opt/app
    remote_src: true
    creates: /opt/app/VERSION

When no module fits, keep the property by hand with creates:, removes:, or an explicit changed_when:. A playbook where every run reports changes is a playbook nobody can read the output of.

Check mode

bash
ansible-playbook -i inventory.ini site.yml --check --diff

--check makes no changes and reports what would happen; --diff shows the actual text difference for files and templates. This is the closest thing Ansible has to terraform plan, and it only works as well as your modules' idempotence — another reason shell: everywhere costs you something real.

Roles

A role is the standard directory layout for reusable configuration:

roles/nginx/
  tasks/main.yml        # what to do
  handlers/main.yml     # what to notify
  templates/            # .j2 files
  files/                # copied verbatim
  defaults/main.yml     # variables, lowest precedence — overridable
  vars/main.yml         # variables, high precedence — not meant to be overridden

The defaults versus vars distinction is the one that causes confusion. Anything a user of the role should be able to change belongs in defaults/. Ansible's full variable precedence has many levels; the practical rule is that defaults/ loses to almost everything and -e on the command line beats everything.

Vault

bash
ansible-vault encrypt group_vars/production/secrets.yml
ansible-vault view group_vars/production/secrets.yml
ansible-playbook site.yml --ask-vault-pass

Vault encrypts the file in the repository so the ciphertext is what gets committed. Two things it does not do: it does not keep the plaintext out of your shell history if you went via a temporary file, and it does not help if the secret was committed before you started — see Why secrets do not belong in Git.

Encrypt whole files rather than individual values where you can. A file is obviously encrypted; a single !vault string in an otherwise plain file is easy to move somewhere it will not be.

Common mistakes

  1. shell: where a module exists. You lose idempotence, check mode, and meaningful changed reporting all at once.
  2. Unquoted file modes. mode: 644 is decimal, not the octal you meant, and mode: 0644 only works most of the time.
  3. become: true on the whole play when two tasks need it. Scope privilege to the tasks that require it.
  4. Running against all while testing. --limit web01.example.com first, then the group. Ansible is extremely good at doing the wrong thing to two hundred machines in parallel.
  5. Configuration management as a deployment mechanism. Converging a running server towards a desired state is a different thing from shipping a new immutable artefact — see Why artifacts should be immutable.

If this did not work

  • "Failed to connect to the host via ssh". Test plain ssh to the host first. Usually it is the user, the key, or a host key prompt waiting on input nobody can give it.
  • "Missing sudo password". The remote user needs passwordless sudo, or the run needs --ask-become-pass.
  • A task reports changed on every run. It is command/shell without creates: or changed_when:, or a template whose content genuinely differs each time — a timestamp in the file will do it.
  • A variable is not the value you set. Precedence. Print it with a debug: task at the point it is used rather than reasoning about the order.
  • YAML will not parse. Paste it into the YAML and JSON converter — indentation errors are much easier to see once something else has read the structure.

Next steps

Write one role for one thing you currently do by hand, and run it with --check --diff against a server you have already configured manually. Where it reports a change, either your role or your server is wrong — and finding out which is the whole exercise.

Tagged with

Enjoyed this article?

Get more DevOps insights delivered to your inbox.

Get new posts by email

Subscribe to get an email when a new blog post is published. Skip anytime.

No spam, unsubscribe anytime.

N

Written by

Neeraj Jha

Platform administrator and lead writer.

View all posts →

Discussion

0 comments

Sign in to join the conversation.

Be the first to comment

Start a conversation about this post

Share: