Every sysadmin writes the same script eventually. setup.sh: install these packages, copy this config, create this user, start this service. It works the first time. Then you run it again on a machine that is half-configured and it fails, or worse, it does not fail and quietly does something wrong.

The problem is that a shell script describes steps. Ansible describes the end state — “nginx should be installed and running, this file should have these contents” — and works out what to change. Run it against a fresh machine and it does everything; run it against a configured one and it does nothing.

That property is called idempotence, and it is the entire reason to adopt a configuration management tool.

Is it worth your time?

Yes, past about three servers — or one server you would be in trouble if you lost. The moment “how was this configured?” is answered by memory rather than a file, you want this.

Not for one machine you set up once. A shell script and good notes are genuinely fine, and pretending otherwise wastes a weekend.

Ansible over the alternatives mostly because it needs nothing installed on the servers — just SSH and Python, which you already have.

What makes it different from a script

Shell scriptAnsible
DescribesSteps to takeState to reach
Running it twiceOften breaksChanges nothing
Partial failureLeaves a messStops, reports which task
Many serversA loop you writeBuilt in, parallel
Preview changesNo--check
Needs an agent installedNoNo — just SSH
Learning curveYou know it alreadyA weekend
Good for one-off tasksYesOverkill

The agentless row is why Ansible won. Puppet and Chef want software running on every managed machine; Ansible connects over SSH, does its work, and disconnects. If you can log in, you can manage it.

Installing

Ansible is installed only on your own machine — the control node. Nothing goes on the servers.

sudo apt install ansible
sudo dnf install ansible
sudo pacman -S ansible

# Or, for a newer version than your distro ships
pipx install --include-deps ansible

ansible --version

The servers need SSH access with key authentication, a Python interpreter (every modern distribution has one), and sudo. That is the whole requirement, and you have already done all three if you followed securing a new server.

The inventory

A list of the machines you manage, grouped however is useful.

# inventory.ini
[web]
web-01 ansible_host=203.0.113.10
web-02 ansible_host=203.0.113.11

[db]
db-01 ansible_host=203.0.113.20

[all:vars]
ansible_user=deploy
ansible_python_interpreter=/usr/bin/python3
# Check you can reach everything
ansible all -i inventory.ini -m ping

# Run one command everywhere
ansible web -i inventory.ini -a "uptime"

# With sudo
ansible all -i inventory.ini -b -a "systemctl status nginx"

Those ad-hoc commands are useful before you write a single playbook. “Run uptime on all twelve servers” is a one-liner, and it is often how people start using Ansible without meaning to.

A first playbook

A playbook is a YAML file describing the desired state. This one sets up a web server:

# webserver.yml
---
- name: Configure web servers
  hosts: web
  become: true

  tasks:
    - name: Install packages
      ansible.builtin.package:
        name:
          - nginx
          - ufw
          - tmux
        state: present

    - name: Deploy the site configuration
      ansible.builtin.template:
        src: templates/site.conf.j2
        dest: /etc/nginx/sites-available/default
        owner: root
        mode: '0644'
      notify: Reload nginx

    - name: Allow HTTP and HTTPS
      community.general.ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop: [80, 443]

    - name: Ensure nginx is running and enabled
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
# See what WOULD change, without changing anything
ansible-playbook -i inventory.ini webserver.yml --check --diff

# Do it
ansible-playbook -i inventory.ini webserver.yml

# Just one server
ansible-playbook -i inventory.ini webserver.yml --limit web-01

Three things in that playbook are worth understanding, because they are most of what makes Ansible worth using.

state: present and state: started are descriptions, not commands. Ansible checks whether nginx is installed and installs it only if not. Run the playbook ten times and nine of them report zero changes.

notify and handlers mean things restart only when they need to. The reload handler runs only if the config file actually changed, so a no-op playbook run does not bounce your web server for nothing.

--check --diff is the killer feature. It reports exactly what would change, including a diff of every file, without touching anything. There is no shell-script equivalent, and on production it is the difference between confidence and hoping.

The modules you will actually use

Ansible ships thousands. About eight cover most real work.

ModuleFor
packageInstall software, distribution-agnostic
serviceStart, stop, enable
copyPut a file in place
templateA file with variables filled in
lineinfileChange one line in an existing file
user / groupAccounts
fileDirectories, permissions, symlinks
cronScheduled jobs
command / shellEscape hatch — see below

package deserves a note: it picks apt, dnf or pacman based on the target, so one playbook covers Debian and Red Hat machines. That only goes so far — package names still differ, and config file paths differ more, as Debian and Fedora both show — but it removes one layer of difference.

The command and shell modules are where good playbooks go wrong. They run arbitrary commands, which means they are not idempotent: Ansible cannot know whether the state already matched, so it reports “changed” every time and re-runs the command on every play. A playbook made mostly of shell tasks is a shell script wearing a costume, with all the original problems plus YAML.

When you genuinely need one, guard it:

- name: Build the search index
  ansible.builtin.command: /usr/local/bin/build-index
  args:
    creates: /var/lib/myapp/index.db      # skip if this already exists

creates and removes restore idempotence by giving Ansible a way to check. Use one of them on every command task you write.

Secrets

Playbooks belong in version control, and version control is a poor place for database passwords. Ansible Vault encrypts values in place:

# Create an encrypted variables file
ansible-vault create group_vars/all/secrets.yml

# Edit it later
ansible-vault edit group_vars/all/secrets.yml

# Run a playbook that uses it
ansible-playbook -i inventory.ini site.yml --ask-vault-pass

The encrypted file is safe to commit. Variables inside it are used exactly like any other, so nothing else in the playbook changes.

Things that catch people out

SymptomCause
YAML errors on a value like 0644Quote it — YAML reads it as a number and drops the zero
Every run reports “changed”You are using shell or command without creates
Permission denied on the targetMissing become: true
Host unreachableSSH key or ansible_user wrong — test with plain ssh first
Works on one distro, not anotherPackage or path names differ
Handler never runsThe task did not report a change, so nothing notified it
Indentation errors constantlyYAML. Use an editor with a YAML mode.

The file-mode one bites everybody once. mode: 0644 is octal to you and decimal to YAML; always write mode: '0644' in quotes. File permissions covers what those digits mean.

When Ansible is the wrong answer

  • One server, configured once. The honest answer is a shell script and a written record. Ansible pays off through repetition, and one machine does not repeat.
  • Creating infrastructure. Ansible configures machines that exist. Creating the VMs, networks and DNS records is Terraform’s job, or your provider’s console.
  • Containers. If the application ships as an image, the Dockerfile is the configuration — see getting started with Docker. Ansible is still useful for the host underneath.
  • Anything urgent and interactive. During an incident, SSH in and fix it. Write the playbook afterwards so the fix survives.
  • As a deployment tool, in most cases. It can deploy applications and it is rarely the best thing for it.

A reasonable adoption path: keep using shell scripts, and the first time you set up a second server that should match the first, convert that script into a playbook. The comparison makes the value obvious in a way that reading about idempotence does not.

Quick reference

You wantCommand
Can I reach everything?ansible all -i inv -m ping
Run a command everywhereansible web -i inv -a "uptime"
Preview changesansible-playbook -i inv play.yml --check --diff
Applyansible-playbook -i inv play.yml
One host only--limit web-01
Skip ahead to a task--start-at-task "Deploy config"
See what a host looks likeansible web-01 -i inv -m setup
Check syntaxansible-playbook play.yml --syntax-check
Encrypt secretsansible-vault create secrets.yml

Related reading