Ansible Basics: Configuration Management Without Agents

Ansible Basics: Configuration Management Without Agents

Ansible configures machines over SSH. There is no agent to install, no central server required, and no daemon running on the targets.

You write what the system should look like. Ansible connects, checks what is actually there, and changes only what differs.

How it works

The mechanism is simpler than most people expect, and worth understanding because it explains the constraints.

Ansible connects over SSH, copies a small Python module to a temporary directory on the target, executes it, captures the JSON it prints, deletes it, and disconnects. Every task is that cycle.

Consequences of that design:

Nothing runs on the targets between playbook runs. No agent, no daemon, no memory footprint, and nothing to keep patched.

Requirements are minimal. SSH access and a Python interpreter. Both are already present on essentially every Linux system.

It is push-based. You run Ansible from your machine and it pushes out. Nothing polls a server for instructions, which is a meaningful security difference from agent-based systems.

It is slower than agent-based tools at scale. Every task is an SSH round trip. For a handful of servers this is irrelevant; for thousands it matters, and there are mitigations.

Since it rides on SSH, your existing SSH setup applies directly. Our SSH config builder and key-based authentication guide are the prerequisite, because Ansible prompting for a password on every host defeats the purpose.

Installing

sudo apt install ansible        # Debian, Ubuntu
sudo dnf install ansible        # Fedora, RHEL
pipx install ansible            # any distribution, current version

ansible --version

Install on your workstation or a control machine. Not on the targets.

The inventory

A list of hosts, grouped.

# inventory.ini
[web]
web1.example.com
web2.example.com

[db]
db1.example.com ansible_user=postgres

[production:children]
web
db

[all:vars]
ansible_user=admin
ansible_python_interpreter=/usr/bin/python3

Groups let you target sets of machines. production:children makes a group of groups.

Verify connectivity before anything else:

ansible all -i inventory.ini -m ping

ping here is not ICMP. It connects over SSH, runs a Python module, and confirms the whole chain works. If this fails, nothing else will.

Ad-hoc commands

Useful before you write a single playbook:

# uptime everywhere
ansible all -i inventory.ini -a "uptime"

# disk usage on web servers
ansible web -i inventory.ini -a "df -h /"

# install a package, needs privilege escalation
ansible web -i inventory.ini -b -m apt -a "name=htop state=present"

# restart a service
ansible web -i inventory.ini -b -m systemd -a "name=nginx state=restarted"

-b means become, which is sudo by default. -a passes arguments to the module, -m selects the module.

Running one command across fifty machines and collecting the output is genuinely useful on its own, before any configuration management.

Your first playbook

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

  vars:
    app_port: 8080

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

    - name: Deploy configuration
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/conf.d/app.conf
        owner: root
        group: root
        mode: '0644'
        validate: nginx -t -c %s
      notify: Reload nginx

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

  handlers:
    - name: Reload nginx
      ansible.builtin.systemd:
        name: nginx
        state: reloaded
ansible-playbook -i inventory.ini site.yml

Several things here are worth pointing at.

package rather than apt or dnf. The generic module picks the right package manager for the target, so the same playbook works across distributions.

notify and handlers. The handler runs only if the task reported a change, and only once at the end of the play even if notified several times. This is how you avoid reloading nginx on every run when nothing changed.

validate. The template module tests the config with nginx -t before putting it in place. If the generated config is invalid, the task fails and the broken file never lands. Use this on anything where a bad config breaks the service, especially sshd_config.

mode: '0644' is quoted. Unquoted, YAML parses 0644 as an integer and you get the wrong permissions. This bites people regularly.

Idempotency

The central idea. Run a playbook once and it makes changes. Run it again and it changes nothing.

PLAY RECAP
web1 : ok=4 changed=3 unreachable=0 failed=0   # first run
web1 : ok=4 changed=0 unreachable=0 failed=0   # second run

Modules achieve this by checking state before acting. package: state=present looks to see whether the package is installed and does nothing if it is.

This is what makes a playbook safe to run repeatedly, and it is what lets you use check mode:

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

--check reports what would change without changing it. --diff shows the actual file differences. Running this against production before a real run is a habit worth forming.

shell and command break idempotency. They run every time and report changed every time, and check mode cannot predict them.

# poor: runs every time
- name: Create directory
  ansible.builtin.shell: mkdir -p /opt/app

# correct: idempotent, and sets ownership properly
- name: Create directory
  ansible.builtin.file:
    path: /opt/app
    state: directory
    mode: '0755'

When you genuinely must use shell, constrain it:

- name: Initialise the database
  ansible.builtin.shell: /opt/app/init-db.sh
  args:
    creates: /var/lib/app/.initialised

creates makes the task skip if that file exists, restoring idempotency by hand.

Templates

Jinja2, with variables from the playbook and facts from the target.

# templates/nginx.conf.j2
server {
    listen {{ app_port }};
    server_name {{ ansible_fqdn }};

    # {{ ansible_processor_vcpus }} CPUs detected on this host
    worker_processes {{ ansible_processor_vcpus }};

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

ansible_fqdn and ansible_processor_vcpus are facts, gathered automatically at the start of a play. See what is available:

ansible web1 -i inventory.ini -m setup | less

Facts are the reason templating beats copying static files: the same template produces correct config on machines with different CPU counts, addresses, and hostnames.

Secrets

Never commit plaintext credentials.

ansible-vault create secrets.yml
ansible-vault edit secrets.yml
ansible-vault encrypt existing-vars.yml

ansible-playbook site.yml --ask-vault-pass

Vault encrypts with AES256. The encrypted file is safe to commit; the password is not, and belongs in a password manager or a secrets system.

vars_files:
  - secrets.yml

Structure once it grows

A playbook that has grown past a few hundred lines wants roles:

site.yml
inventory.ini
group_vars/
  all.yml
  web.yml
roles/
  common/
    tasks/main.yml
    handlers/main.yml
    templates/
  nginx/
    tasks/main.yml
    templates/nginx.conf.j2
    defaults/main.yml
---
- hosts: web
  become: true
  roles:
    - common
    - nginx

Roles are reusable and shareable. Ansible Galaxy hosts a large collection, which are worth reading and worth auditing before running, since a role is arbitrary code executing as root on your servers.

When Ansible is the wrong tool

One machine, set up once. A shell script is simpler and adding Ansible is overhead you will not recover.

Immutable infrastructure. If you build images and replace instances rather than modifying them, Packer and Terraform fit the model better. Ansible can still build the images.

Real-time enforcement. Ansible runs when you run it. If you need continuous drift correction, Puppet’s agent model is closer to that shape.

The switch point in practice is when you copy the same setup script to a second server. At that moment the script becomes a thing to maintain in two places, and that is what configuration management exists to solve.

Frequently Asked Questions

Does Ansible need an agent on the target machines?

No. Ansible connects over SSH, copies a small Python program to the target, runs it, collects the output, and deletes it. The only requirements on a managed host are SSH access and a Python interpreter, both of which nearly every Linux system already has.

What does idempotent mean in Ansible?

An idempotent task produces the same end state no matter how many times it runs. Ansible modules check current state before changing anything, so a task installing nginx reports changed on the first run and ok on every run afterwards. This is what makes it safe to run a playbook repeatedly.

What is the difference between a playbook and a role?

A playbook is a YAML file listing tasks to run against a group of hosts. A role is a structured directory of tasks, handlers, templates, files, and variables that can be reused across playbooks. Start with playbooks and refactor into roles when you find yourself copying the same tasks between them.

Should I use the shell module or a real Ansible module?

Use a real module whenever one exists. Modules check state and only act when needed, while shell and command run every time and report changed every time, which breaks idempotency and makes check mode useless. Reach for shell only when nothing else covers the task.

How do I store secrets in Ansible?

Use Ansible Vault, which encrypts variable files with a password. Run ansible-vault encrypt on the file and supply the password at playbook run time with —ask-vault-pass or a password file. Never commit plaintext passwords or keys to a repository, encrypted or not.

Is Ansible better than a shell script?

For anything run more than once across more than one machine, usually yes, because of idempotency, built-in error handling, and check mode. For a single machine set up once, a shell script is simpler and adding Ansible is overhead. The switch point is roughly when you start copying the same script to a second server.