Ansible Quickstart — For Dummies
What Ansible is, in three sentences
Ansible is a tool you run on your laptop that SSHes into other machines and makes them match a description you wrote in a YAML file. You describe the end state ("nginx is installed, this config file is in place, the service is running"), and Ansible figures out what to change to get there. It runs the same playbook against 1 server or 1000 servers, and running it a second time does nothing if nothing needs changing.
That's it. No agents on the remote hosts, no daemon, no database. Just SSH and Python.
Install it on your laptop
You only install it on the machine you'll run commands from (your laptop, a jump host, a CI runner). The remote machines need nothing beyond SSH and a Python interpreter — which already exists on every Linux server.
# On macOS
brew install ansible
# On Debian / Ubuntu
sudo apt install ansible
# On RHEL / Rocky / Fedora
sudo dnf install ansible
# Or the universal way — isolated Python install
python3 -m pip install --user pipx
pipx install ansible
# Check it works
ansible --version
pipx is the recommended path if you want the latest version and don't want to fight your distro's package manager.
The three things you'll touch
- An inventory file — a plain-text list of hosts to manage.
- A playbook — a YAML file describing what you want those hosts to look like.
- The
ansible-playbookcommand — runs a playbook against an inventory.
That's the entire product surface for the first week. Everything else (roles, collections, vaults, variables) is an organisational convenience on top of these three things.
Write an inventory
Make a new directory and put a file called hosts.ini in it with one host you can SSH into.
mkdir ~/ansible-first
cd ~/ansible-first
cat > hosts.ini <<'EOF'
[dev]
lab01.example.com ansible_user=ubuntu
EOF
That file says: there is a group called dev with one host, and Ansible should SSH in as the user ubuntu. Replace with a hostname you actually have, and a user you can SSH to with key auth.
If you can run ssh lab01.example.com and land in a shell without typing a password, Ansible can do its thing. If you can't, fix SSH first — see SSH Keys.
Ad-hoc: your first command
Before writing a playbook, just prove the connection works. An "ad-hoc" command runs a single module against the inventory.
# Say hello — the ping module checks SSH + Python on the remote
ansible -i hosts.ini all -m ping
# Expected output:
# lab01.example.com | SUCCESS => {
# "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3" },
# "changed": false,
# "ping": "pong"
# }
If that printed SUCCESS, you're done with setup. Everything else is just writing YAML.
A couple more useful ad-hoc commands while you have the momentum:
# Run a shell command on every host in the group
ansible -i hosts.ini dev -m shell -a 'uptime'
# Gather and print facts (tons of info about the remote host)
ansible -i hosts.ini dev -m setup | head -40
Your first playbook
Create a file called first.yml:
---
- name: My very first playbook
hosts: dev
become: true
tasks:
- name: Make sure htop is installed
ansible.builtin.package:
name: htop
state: present
- name: Write a friendly message of the day
ansible.builtin.copy:
dest: /etc/motd
content: "Managed by Ansible. Do not hand-edit.\n"
owner: root
group: root
mode: '0644'
Run it:
ansible-playbook -i hosts.ini first.yml
Expected output (trimmed):
PLAY [My very first playbook] ***************************************
TASK [Gathering Facts] **********************************************
ok: [lab01.example.com]
TASK [Make sure htop is installed] **********************************
changed: [lab01.example.com]
TASK [Write a friendly message of the day] **************************
changed: [lab01.example.com]
PLAY RECAP **********************************************************
lab01.example.com : ok=3 changed=2 unreachable=0 failed=0
changed=2 means Ansible actually did something — htop wasn't installed before, motd wasn't correct. SSH in and check: htop runs, cat /etc/motd shows your message.
What just happened, line by line
---
- name: My very first playbook # human-readable play name (prints in output)
hosts: dev # which inventory group to target
become: true # run tasks as root (via sudo on the remote)
tasks: # ordered list of things to do
- name: Make sure htop is installed
ansible.builtin.package: # the "package" module — distro-agnostic
name: htop
state: present # desired state: must exist
- name: Write a friendly message of the day
ansible.builtin.copy: # the "copy" module — writes files
dest: /etc/motd # where the file goes on the remote
content: "..." # the literal content (instead of src:)
owner: root # file ownership
group: root
mode: '0644' # permissions
Every task has a human name, a module name (ansible.builtin.package, ansible.builtin.copy), and a set of arguments. The module does the actual work — Ansible is mostly just shuttling arguments to the right module on the right host.
The two modules above (package and copy) plus service, file, lineinfile, user, template, and command cover the first year of real-world usage.
Run it again — idempotency
Without changing anything, run the same command again:
ansible-playbook -i hosts.ini first.yml
PLAY RECAP **********************************************************
lab01.example.com : ok=3 changed=0 unreachable=0 failed=0
changed=0. htop was already installed; motd already had the right content. Ansible saw that the desired state was already met and did nothing.
This is called idempotency and it is the single most important idea in the tool. You are not writing a script of actions. You are declaring a state. Ansible only acts when the actual state doesn't match.
Variables, the short version
Hard-coding htop in a task is fine for one box. For a real fleet you'll want variables. Three places they come from, in order of increasing specificity:
- Inline in the playbook — quick and dirty.
group_vars/<group>.yml— applies to every host in that group.host_vars/<hostname>.yml— applies to one specific host (overrides group).
Example structure:
ansible-first/
├── hosts.ini
├── first.yml
├── group_vars/
│ └── dev.yml # variables for every host in the [dev] group
└── host_vars/
└── lab01.example.com.yml # just for this one host
group_vars/dev.yml:
---
motd_message: "Dev environment — reboots at 03:00 UTC daily."
packages_to_install:
- htop
- jq
- curl
Updated first.yml that uses those variables:
---
- name: My very first playbook (with variables)
hosts: dev
become: true
tasks:
- name: Install baseline packages
ansible.builtin.package:
name: "{{ packages_to_install }}"
state: present
- name: Write motd
ansible.builtin.copy:
dest: /etc/motd
content: "{{ motd_message }}\n"
owner: root
group: root
mode: '0644'
The {{ ... }} is Jinja2. Don't worry about it yet — just know curly braces mean "substitute a variable here".
Handlers in one paragraph
When you change a config file you usually want to restart a service — but only if something actually changed. That's a handler: a task that only runs when another task notifys it.
---
- name: Configure nginx
hosts: web
become: true
tasks:
- name: Deploy nginx config
ansible.builtin.copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
mode: '0644'
notify: Restart nginx
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
First run: config changes, handler fires, nginx restarts. Second run: config already correct, handler doesn't fire. Zero unnecessary restarts. See Handlers & Templates for the full picture.
Where to go next
You now understand what Ansible is and how to use it at the simplest level. The other pages on this site build on exactly this foundation — none of them introduce a new fundamental concept, they just show you how real production repos are organised.
- 06 · Ansible — the reference page: inventory, modules, idempotency, best practices in depth.
- Roles in Practice — once your playbook has 20 tasks, you split it into roles.
- Project Structure — how a real repo is laid out: inventories/production/, group_vars, site.yml.
- Variable Precedence — the full 22-rule chain for when variables conflict.
- Debugging —
-vvv,--check --diff, thedebugmodule. - Tags — run only part of a big playbook.
- Cheatsheet — one-page command and syntax reference for later.