You set up a server by hand: install packages, create a user, edit a config file, restart a service. Six months later you need a second server and nobody remembers the exact steps. Ansible solves this by turning the steps into a file you can read, review and run again.

This guide takes you from nothing to a working playbook that configures an Ubuntu server with Nginx. It assumes you can SSH into a Linux machine.

What Ansible does

Ansible is an agentless automation tool. It connects to your servers over SSH, runs small programs called modules, and reports what changed. Nothing needs to be installed on the servers except Python, which Ubuntu already has.

Three ideas carry most of the weight:

  • Inventory: the list of machines to manage.
  • Playbook: a YAML file describing the desired state of those machines.
  • Idempotency: running a playbook twice gives the same result. The second run should report no changes.

Install Ansible on your control machine

The control machine is your laptop or a CI runner. Current ansible-core releases need Python 3.12 or newer on the control machine (the 2.20 line supports 3.12 to 3.14), while managed servers can run older Python versions. The simplest install is with pipx:

bash
pipx install --include-deps ansible
ansible --version

Windows is not supported as a control machine; use WSL instead.

Write an inventory

Create a project folder and an inventory.ini file:

text
[web]
web1 ansible_host=203.0.113.10

[web:vars]
ansible_user=ubuntu

Test the connection with the ping module, which checks SSH and Python rather than sending ICMP:

bash
ansible -i inventory.ini web -m ansible.builtin.ping

A green "ping": "pong" means Ansible can manage the server.

Your first playbook

Create site.yml:

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

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

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

Run it:

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

become: true runs tasks with sudo. Each task uses a module with fully qualified names such as ansible.builtin.apt. Run the playbook again and every task should report ok instead of changed. That is idempotency in action.

Templates and handlers

Real configuration needs files. Ansible renders Jinja2 templates and can restart services only when a file actually changes, using handlers.

Create templates/site.conf.j2:

text
server {
    listen 80;
    server_name {{ domain }};
    root /var/www/{{ domain }};
    index index.html;
}

Extend the playbook:

yaml
- name: Configure web servers
  hosts: web
  become: true
  vars:
    domain: example.com

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

    - name: Create web root
      ansible.builtin.file:
        path: "/var/www/{{ domain }}"
        state: directory
        owner: www-data
        mode: "0755"

    - name: Deploy site config
      ansible.builtin.template:
        src: templates/site.conf.j2
        dest: "/etc/nginx/sites-available/{{ domain }}"
        mode: "0644"
      notify: Reload Nginx

    - name: Enable site
      ansible.builtin.file:
        src: "/etc/nginx/sites-available/{{ domain }}"
        dest: "/etc/nginx/sites-enabled/{{ domain }}"
        state: link
      notify: Reload Nginx

  handlers:
    - name: Reload Nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

Handlers run once at the end of the play, and only if a task that notifies them reported a change. Change the template and Nginx reloads; run again with no changes and nothing restarts.

Check before you change

Two flags make playbooks safer to run:

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

--check shows what would change without changing it, and --diff prints file differences. Use both before running against production. A few modules cannot predict their result in check mode, so treat it as a strong hint rather than a guarantee.

Keep secrets out of Git with Ansible Vault

Never commit passwords or API keys in plain text. Ansible Vault encrypts files:

bash
ansible-vault create group_vars/web/vault.yml

Inside, store secrets with a vault_ prefix, for example vault_db_password: "...". Reference them from a normal variables file so it is obvious where a value comes from. Run playbooks with --ask-vault-pass, or point --vault-password-file at a file your CI injects at runtime.

Organise with roles as you grow

When a playbook passes a hundred lines, split it into roles. Create one with:

bash
ansible-galaxy role init roles/nginx

A role has standard folders such as tasks/, handlers/, templates/ and defaults/. Your playbook then becomes a short list of roles applied to host groups, and roles can be shared across projects.

Lint your playbooks

ansible-lint catches common mistakes such as missing names, non-FQCN module names and risky shell usage:

bash
pipx install ansible-lint
ansible-lint site.yml

Run it in CI on every pull request, just like a code linter.

Common beginner mistakes

  • Using `shell` for everything. Prefer modules like apt, file and template; they are idempotent and report changes correctly.
  • Restarting services in tasks. Use handlers so services restart only when needed.
  • Hard-coding hosts and values. Put them in the inventory and group_vars/.
  • Skipping `--check --diff`. It costs seconds and catches surprises.

Key takeaways

  • Ansible is agentless: it manages servers over SSH with Python modules.
  • An inventory lists machines; a playbook declares their desired state.
  • Good playbooks are idempotent: a second run reports no changes.
  • Use templates with handlers so services reload only when config changes.
  • Protect secrets with Ansible Vault and lint playbooks in CI.

Sources: Ansible community documentation, ansible-core 2.20 porting guide, Releases and maintenance.