Skip to content
Pipelines and Pizza 🍕
Go back

Ansible for Beginners: The Fastest Way to Automate Configs

9 min read

“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 nginx will 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_port if 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:

FlagWhat It Does
-iPoints to your inventory file
-mThe module to use (command, apt, service, etc.)
-aArguments for that module
--becomeRun 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. The apt task only runs on Debian-family systems. If you have CentOS boxes too, you’d add a yum task with when: 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.
  • package instead of apt — The generic package module 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

MistakeWhy It’s a ProblemWhat to Do Instead
Using shell or command for everythingBypasses idempotency — Ansible can’t tell if work was already doneUse the right module (apt, service, user, copy, etc.)
Hardcoding values in tasksCan’t reuse across environmentsUse variables and defaults/main.yml
Skipping --check --diffApplying blind changes to productionAlways dry-run first, especially on prod
No name on tasksPlaybook output is unreadable — you get module names instead of descriptionsEvery task gets a human-readable name
Giant monolithic playbooksHard to read, impossible to reuseBreak 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!