“Automate the boring stuff — before it becomes confusing stuff.”
If you’re managing more than a couple of servers and you’re still SSH-ing in to run the same commands by hand, Ansible is about to change your life. I don’t say that lightly — I’ve watched teams go from “we have a wiki page with the setup steps” to “we run one command and everything is configured” in a single sprint.
This post is for you if you’ve heard of Ansible but haven’t used it yet, or if you tried it once and the docs felt overwhelming. We’re going to go from zero to a working playbook that does something actually useful.
What Is Ansible and Why Should You Care?
Ansible is an automation tool that lets you describe what you want your servers to look like, and then makes it happen. The key things that make it different from writing bash scripts:
- Agentless — It connects over SSH. No daemon to install, no ports to open, no client to maintain on every server. If you can SSH into a box, Ansible can manage it.
- Idempotent — Run the same playbook ten times, and it only changes what actually needs changing. This is the big one. A bash script that runs
apt install nginxwill try to install it every time. An Ansible task checks first. - YAML-based — Your infrastructure config is readable by humans, diffable in Git, and reviewable in pull requests. No proprietary DSL to learn.
- Huge module library — Thousands of modules for managing packages, services, files, users, cloud resources, network devices, and more. You rarely need to shell out to raw commands.
Installing Ansible
Linux / macOS
pip install ansible
Or on macOS:
brew install ansible
Windows
Ansible’s control node doesn’t run on Windows natively. Use WSL2:
# Inside WSL2 (Ubuntu)
sudo apt update && sudo apt install -y python3-pip
pip install ansible
Verify It Works
ansible --version
You should see the version number and config file path. If you get an error about Python, make sure python3 is in your PATH.
The Inventory — Telling Ansible What to Manage
Before Ansible can do anything, it needs to know which servers to talk to. That’s the inventory — a file listing your hosts, optionally organized into groups.
Example: inventory.ini
[web]
web1.example.com
web2.example.com
[db]
db1.example.com
[web:vars]
ansible_user=deploy
ansible_python_interpreter=/usr/bin/python3
A few things to notice:
- Groups (
[web],[db]) let you target subsets of your infrastructure. You’ll run playbooks against groups, not individual hosts. - Group variables (
[web:vars]) set connection details for all hosts in that group. This keeps your playbooks clean — no hardcoded usernames. - You can also use IP addresses instead of hostnames, and add
ansible_portif SSH isn’t on port 22.
Test connectivity:
ansible -i inventory.ini all -m ping
If you see green SUCCESS for each host, you’re connected. If you see red, it’s almost always an SSH key or username issue — check ansible_user in your inventory.
Ad-Hoc Commands — Quick One-Liners
You don’t need a playbook for everything. Ad-hoc commands are great for quick checks and one-off tasks:
# Check disk space on all web servers
ansible -i inventory.ini web -m command -a "df -h /"
# Check uptime
ansible -i inventory.ini all -m command -a "uptime"
# Install a package (needs sudo)
ansible -i inventory.ini web -m apt -a "name=curl state=present" --become
# Restart a service
ansible -i inventory.ini web -m service -a "name=nginx state=restarted" --become
The flags:
| Flag | What It Does |
|---|---|
-i | Points to your inventory file |
-m | The module to use (command, apt, service, etc.) |
-a | Arguments for that module |
--become | Run with sudo (needed for package installs, service management) |
Ad-hoc commands are powerful for quick troubleshooting — “is this service running on all my servers?” — but for anything repeatable, you want a playbook.
Your First Playbook
A playbook is a YAML file that describes a series of tasks to run against a group of hosts. Here’s one that does something actually useful — setting up a new server with your team’s baseline configuration:
baseline.yml
---
- name: Baseline server configuration
hosts: all
become: yes
vars:
admin_users:
- name: deploy
ssh_key: "ssh-ed25519 AAAA... deploy@company.com"
- name: monitoring
ssh_key: "ssh-ed25519 AAAA... monitoring@company.com"
required_packages:
- curl
- wget
- htop
- git
- unzip
- jq
tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Install required packages
package:
name: "{{ required_packages }}"
state: present
- name: Create admin users
user:
name: "{{ item.name }}"
shell: /bin/bash
groups: sudo
append: yes
state: present
loop: "{{ admin_users }}"
- name: Add SSH keys for admin users
authorized_key:
user: "{{ item.name }}"
key: "{{ item.ssh_key }}"
state: present
loop: "{{ admin_users }}"
- name: Harden SSH - disable root login
lineinfile:
path: /etc/ssh/sshd_config
regexp: "^PermitRootLogin"
line: "PermitRootLogin no"
notify: Restart SSH
- name: Harden SSH - disable password auth
lineinfile:
path: /etc/ssh/sshd_config
regexp: "^PasswordAuthentication"
line: "PasswordAuthentication no"
notify: Restart SSH
- name: Set timezone
timezone:
name: America/Chicago
handlers:
- name: Restart SSH
service:
name: sshd
state: restarted
Let’s break down what’s happening here:
vars— Variables defined at the top keep the playbook DRY. Add a new user? Add an entry to the list. Add a package? Same.when— Conditional execution. Theapttask only runs on Debian-family systems. If you have CentOS boxes too, you’d add ayumtask withwhen: ansible_os_family == "RedHat".loop— Iterates over a list. One task creates all your admin users instead of copying the task for each person.notify/handlers— The SSH tasks notify a handler to restart sshd, but only if the task actually changed something. If the config was already correct, no restart.packageinstead ofapt— The genericpackagemodule works across distros. Use it when you don’t need distro-specific features.
Run it:
ansible-playbook -i inventory.ini baseline.yml
Dry run first (always a good idea):
ansible-playbook -i inventory.ini baseline.yml --check --diff
--check shows what would change without touching anything. --diff shows the actual file diffs. I run this before every real apply, especially on production.
Variables and Facts
Ansible gives you two kinds of data to work with:
Variables You Define
In the playbook (as shown above), in separate files, or passed on the command line:
ansible-playbook -i inventory.ini site.yml -e "env=production"
Facts Ansible Gathers Automatically
Every time a playbook runs, Ansible collects system information — OS, IP addresses, memory, disk space. You can use these in conditions and templates:
- name: Show OS info
debug:
msg: "This is {{ ansible_distribution }} {{ ansible_distribution_version }} with {{ ansible_memtotal_mb }}MB RAM"
See all facts for a host:
ansible -i inventory.ini web1.example.com -m setup
The output is huge, but it’s incredibly useful. You’ll find yourself using ansible_os_family, ansible_default_ipv4.address, and ansible_hostname constantly in real playbooks.
Templates — Dynamic Config Files
Static file copies only get you so far. When config files need to change per server or per environment, Jinja2 templates are the answer.
templates/motd.j2
#############################################
# {{ ansible_hostname }}
# OS: {{ ansible_distribution }} {{ ansible_distribution_version }}
# Managed by Ansible - do not edit manually
#############################################
Playbook task:
- name: Deploy MOTD banner
template:
src: motd.j2
dest: /etc/motd
owner: root
mode: '0644'
Each server gets its own hostname and OS in the banner. Templates use the same variable and fact system as the rest of Ansible — anything you can reference in a task, you can reference in a template.
Roles — Organizing for Real Projects
Once your playbook grows past 50-60 lines, it’s time to organize. Roles break your automation into reusable, self-contained units:
roles/
baseline/
tasks/main.yml # The tasks
handlers/main.yml # Handlers
templates/ # Jinja2 templates
files/ # Static files
vars/main.yml # Role variables
defaults/main.yml # Default values (overridable)
Use roles in a playbook:
---
- name: Configure all servers
hosts: all
become: yes
roles:
- baseline
- monitoring
- name: Configure web servers
hosts: web
become: yes
roles:
- nginx
The power of roles is reusability. Your baseline role works on every server. Your nginx role only runs on web servers. When a new project needs the same setup, you bring the role — not copy-paste from an old playbook.
Hands-On Lab: Build a Baseline Role
Let’s turn the playbook from earlier into a proper role structure.
1. Create the directory structure:
mkdir -p roles/baseline/{tasks,handlers,templates,defaults}
2. roles/baseline/defaults/main.yml — Default variables:
---
required_packages:
- curl
- wget
- htop
- git
- jq
timezone: America/Chicago
3. roles/baseline/tasks/main.yml — The tasks:
---
- name: Install required packages
package:
name: "{{ required_packages }}"
state: present
- name: Set timezone
timezone:
name: "{{ timezone }}"
- name: Deploy MOTD
template:
src: motd.j2
dest: /etc/motd
mode: '0644'
4. roles/baseline/templates/motd.j2:
#############################################
# {{ ansible_hostname }} | {{ ansible_distribution }}
# Managed by Ansible - do not edit manually
#############################################
5. site.yml — Top-level playbook:
---
- name: Apply baseline to all servers
hosts: all
become: yes
roles:
- baseline
6. Run it:
ansible-playbook -i inventory.ini site.yml --check --diff
You’ve just gone from a flat playbook to a structured, reusable role. Next time you set up a new environment, you bring the role and override variables as needed.
Common Mistakes to Avoid
| Mistake | Why It’s a Problem | What to Do Instead |
|---|---|---|
Using shell or command for everything | Bypasses idempotency — Ansible can’t tell if work was already done | Use the right module (apt, service, user, copy, etc.) |
| Hardcoding values in tasks | Can’t reuse across environments | Use variables and defaults/main.yml |
Skipping --check --diff | Applying blind changes to production | Always dry-run first, especially on prod |
No name on tasks | Playbook output is unreadable — you get module names instead of descriptions | Every task gets a human-readable name |
| Giant monolithic playbooks | Hard to read, impossible to reuse | Break into roles early |
What’s Next
This gives you the foundation — inventory, ad-hoc commands, playbooks, variables, templates, and roles. In the next article, we’ll put this to work on a real problem: automating monthly patching with rolling updates, pre-flight checks, and post-patching validation.
Happy automating!