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
# 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.
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
---
- 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.
# 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
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
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
shell:where a module exists. You lose idempotence, check mode, and meaningfulchangedreporting all at once.- Unquoted file modes.
mode: 644is decimal, not the octal you meant, andmode: 0644only works most of the time. become: trueon the whole play when two tasks need it. Scope privilege to the tasks that require it.- Running against
allwhile testing.--limit web01.example.comfirst, then the group. Ansible is extremely good at doing the wrong thing to two hundred machines in parallel. - 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
sshto 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
changedon every run. It iscommand/shellwithoutcreates:orchanged_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.
Related on this site
- Why artifacts should be immutable — Where configuration management stops and rebuilding starts.
- Why secrets do not belong in Git — Vault files, and the mistake that precedes them.
- YAML and JSON converter — Check a playbook parses the way you think it does.
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.
Related Posts
Discussion
0 comments
Sign in to join the conversation.
Be the first to comment
Start a conversation about this post



