#DevOps

Configuration Management: A Practical Ansible Getting-Started Guide

SSH in and set up the environment by hand? Slow, unreproducible, error-prone. Ansible automates server management with declarative YAML over SSH, no agent. Covers core concepts (inventory/playbook/task/role), common modules, a hands-on Nginx playbook, and the split with Terraform (infrastructure vs config).

Key Points

  • Topic Focus: DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

I've seen plenty of teams' process for bringing up a new machine: SSH in, apt install nginx, hand-edit nginx.conf, paste in a config snippet, systemctl restart, then ufw allow 80. Ten minutes per machine, an hour for ten machines, and if you're interrupted mid-way you come back having forgotten which step you were on. Next time you scale out, a new hire reconstructs things from 'how we did it last time' WeChat messages, and the environment drifts — production has config version A while the new machine has version B, and a mystical outage is planted right there. Configuration drift is the original sin of manual ops: slow, unreproducible, and inconsistent, all at once. Ansible exists to cure this disease — describe 'what the machine should look like' in a declarative YAML file, then SSH in and make it reality, re-runnable, reviewable, and committable.

1. What Ansible is: declarative automation over SSH

One-line definition: Ansible is a configuration management tool described in YAML and executed over SSH. You write a checklist of 'what state the machine should be in', and it turns the machine into that state.

The essential difference from shell scripts is declarative vs imperative. A shell script says 'first apt install, then cp the config, then systemctl start' — it only cares about actions, not outcomes, so running it twice might install twice, error out, or overwrite. Ansible says 'ensure nginx is installed, the config file content is as follows, the service is running' — it only cares about desired state, so running it once and running it ten times produce the same result. That's idempotence.

This is also one of the key differences from Puppet/Chef: those tools require a resident agent on the target machine, so you have to install the agent first and communicate with a central service, and complexity climbs fast. Ansible just needs the target to be SSH-reachable and have Python installed (almost always the case) — Ansible runs on the control side, and the controlled side needs nothing prepared. For ad-hoc takeovers of a new machine, or managing a small heterogeneous cluster, this is an overwhelming usability advantage.

2. Core concepts: inventory, playbook, task, module, role

Five words to grasp Ansible's entire mental model.

inventory — 'which machines to manage'. An INI/YAML file listing target hosts and groups:

ini
# inventory.ini
[web]
web1.example.com ansible_host=10.0.0.1
web2.example.com ansible_host=10.0.0.2

[db]
db1.example.com ansible_host=10.0.0.10

[prod:children]
web
db

Groups can nest (production contains web and db), and a machine can belong to multiple groups. All operations are by group, so batching is native.

playbook — 'what to do to this pile of machines'. A YAML file made of several plays, each play binding a set of hosts to a set of tasks:

yaml
- name: Deploy web servers
  hosts: web
  become: yes          # sudo privilege escalation
  tasks:
    - name: Install Nginx
      ansible.builtin.apt:
        name: nginx
        state: present

task — a step in a playbook that calls a module with a set of parameters. The name field is for humans; Ansible prints it during execution, so name things to reveal intent — don't write useless stuff like 'execute task 1'.

module — the unit that actually does the work in a task. Ansible ships with hundreds of built-in modules, covering package installation, file copying, service management, user modification, cloud-vendor API operations, and more. Modules are idempotent — the apt module knows 'if the package is installed, skip it', it doesn't dumbly install again.

role — splits a large playbook into reusable units by responsibility. Installing Nginx is one role, installing Redis is another; in a machine's tasks you just include_role: nginx, and the specifics of how to install are encapsulated inside the role. Roles can be packaged and shared; Ansible Galaxy has ready-made ones, and writing your own is simple. Covered in detail later.

3. Idempotent + agentless: Ansible's two foundations

Why can Ansible replace most hand-written ops scripts? These two things.

Idempotent is the core. Running the same playbook repeatedly only actually changes things when the machine's current state differs from the desired state; if they match, it skips. The execution log tells you whether each task was changed or ok — a healthy re-run should be almost entirely ok. This means: you can safely run the same playbook once a day, automatically converging configuration drift — anyone who manually changed the machine gets 'corrected' back. Manually SSH-ing in to change things becomes an anti-pattern under this model, because the next playbook run reverts it for you.

Agentless is the foundation of usability. Control-to-target goes only over SSH; the target needs no resident process, no communication with a central node, and no extra ports. This means: nothing gets 'installed and can't be uninstalled' on the target — Ansible itself leaves no trace (other than the changes it makes); taking over a temporarily-provisioned cloud machine or a customer's intranet machine is doable as long as SSH works; the target's Python is usually bundled with the system, and the few older systems without it can be handled by installing it first via the raw module. The cost is SSH overhead — for thousands-of-machines scale with strong centralized scheduling needs, Puppet/Chef's agent model fits better, but for the vast majority of small and mid-size teams, Ansible's simplicity is justice.

Declarative YAML is a presentation-layer win: readable, diffable, Git-able, with config changes going through PR review and a traceable history. Ops can finally work like writing code.

4. Common module quick reference

For 90% of daily needs these ten-odd modules are enough; memorizing them beats constantly digging through docs.

ModuleWhat it doesTypical usage
apt / yum / dnfInstall packages (Debian / RHEL family)name: nginx state: present
copyCopy a local file to the remotesrc: nginx.conf dest: /etc/nginx/nginx.conf
templateRender with Jinja2 then copy overSame as copy, but src is a .j2 template
service / systemdService managementname: nginx state: started enabled: yes
fileFiles/dirs/permissions/symlinkspath: /var/www state: directory mode: '0755'
user / groupUser and group managementname: deploy shell: /bin/bash
gitPull coderepo: ... dest: /srv/app version: main
command / shellRun arbitrary commandsLast resort, breaks idempotence
debugPrint debug infomsg: "{{ var }}"
assertAssert, stops on failureValidate preconditions

A special note on command and shell: try not to use them. They aren't idempotent by default — write command: systemctl restart nginx and it runs every time, even when there's no need. Whenever a dedicated module can replace it, don't use shell — service over systemctl, file over mkdir/chmod, apt over apt-get. If you really must, pair it with creates/changed_when to explicitly tell Ansible 'under what conditions count as having changed something', reclaiming idempotence.

5. Hands-on playbook: install Nginx + copy config + start + open firewall

Combine the above and write a production-grade playbook you can run directly. Goal: install Nginx on the [web] group machines, copy in a custom config, ensure the service is started and enabled on boot, and allow ports 80 and 443.

yaml
---
# site.yml — Web server baseline configuration
- name: Configure web servers
  hosts: web
  become: yes
  vars:
    nginx_worker_processes: auto

  tasks:
    - name: Update apt cache and install Nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: yes
        cache_valid_time: 3600    # Don't re-update within an hour, saves time

    - name: Copy the Nginx config file
      ansible.builtin.copy:
        src: files/nginx.conf
        dest: /etc/nginx/nginx.conf
        owner: root
        group: root
        mode: '0644'
        validate: 'nginx -t -c %s'   # Syntax-check before copying; if it fails, don't apply
      notify: reload nginx           # Triggered only when config changes; no reload if unchanged

    - name: Ensure Nginx is started and enabled on boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: yes

    - name: Allow HTTP/HTTPS traffic
      community.general.ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop:
        - '80'
        - '443'

  handlers:
    - name: reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

Almost every detail in this playbook is worth calling out:

  • update_cache: yes + cache_valid_time: run apt update before installing, but don't re-refresh the cache within an hour, balancing correctness and speed.
  • validate: 'nginx -t -c %s': %s is the temp file path Ansible substitutes in. If the config syntax is wrong, this step fails outright and won't overwrite with a broken file — production configs must carry validation; this is the key difference from 'just SCP it and wing it'.
  • notify: reload nginx + handlers: a handler runs only when that task was changed, and runs once in a batch after all tasks finish. This means no reload is triggered if the config didn't change, and running the playbook ten times won't pointlessly reload ten times — the essence of idempotence.
  • loop: one task handles multiple ports/users/packages in batch, cleaner than writing three duplicate tasks.
  • become: yes: global sudo privilege escalation, saving you from writing it on every task.

Execution is one line:

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

The output tells you, per machine and per task, whether it was ok, changed, or failed. One YAML description, one command to converge — this is what ops should look like.

6. Variables + Jinja2 templates: generate configs per machine

A playbook with hardcoded config can only manage one class of machine. In real scenarios, web1 and web2 may have different worker counts, different upstream addresses, different domains — that's where variables and templates come in.

Variables come from many sources: the host_vars/ directory per host, group_vars/ per group, written directly in the inventory, declared as vars in the playbook, or injected temporarily with -e on the command line. There are over a dozen precedence levels; remember one sentence: the more specific wins — host_vars beats group_vars beats playbook defaults.

Templates use Jinja2, with the .j2 file suffix, rendered by the template module:

jinja2
{# files/nginx.conf.j2 #}
worker_processes {{ nginx_worker_processes | default('auto') }};

upstream app {
{% for host in groups['app'] %}
    server {{ hostvars[host]['ansible_host'] }}:8080;
{% endfor %}
}

server {
    listen 80;
    server_name {{ server_name }};
    location / {
        proxy_pass http://app;
    }
}
yaml
# group_vars/web.yml
server_name: example.com
nginx_worker_processes: 4

The most powerful part of this template is for host in groups['app'] — it can iterate over every machine in the app group in the inventory and automatically write their IPs into upstream. When scaling out, add a line for a new machine to the inventory and run the playbook once; Nginx's upstream updates automatically, with no manual config editing at all. This is a textbook case of Ansible making 'infrastructure as data' real: the inventory is both the source of truth and the driver of config generation.

7. Role organization: split a large playbook into reusable units

Once a playbook passes two or three hundred lines it gets hard to maintain; real projects split it with roles. A role is a standard directory structure:

text
roles/
  nginx/
    tasks/main.yml      # Main task list
    handlers/main.yml   # Handlers for this role
    templates/          # Jinja2 templates
    files/              # Static files
    vars/main.yml       # Internal role variables
    defaults/main.yml   # Overridable defaults
    meta/main.yml       # Dependency declarations, metadata

A role encapsulates the full logic of 'install and configure some component'; defaults go in defaults/ (lowest precedence, anyone can override), and internal conventions go in vars/. Reference it in a playbook:

yaml
- hosts: web
  roles:
    - role: nginx
    - role: geerlingguy.redis       # Ready-made role installed from Galaxy

A role's value is reuse and standardization. The nginx role you write can be dropped into the next project and used directly; the team has a standard way to 'install Postgres', and no one is allowed to hand-SSH an install again. Ansible Galaxy has thousands of community roles; for wheels small teams shouldn't reinvent (installing Docker, configuring Kafka) just use them directly, but before production use read through its tasks — community role quality varies, so don't trust blindly.

8. Ansible vs Terraform: infrastructure vs configuration

Finally, an unavoidable question: since both are 'infrastructure as code', what's the difference between Ansible and Terraform? Can you use just one?

The division of labor goes like this:

  • Terraform manages creating infrastructure: provisioning machines, creating VPCs, attaching disks, configuring security groups, buying DNS records. It targets cloud-vendor APIs, with state being 'which resources already exist', declaratively diffing out additions and deletions.
  • Ansible manages configuring existing machines: installing software, editing config, and starting services on the machines Terraform provisioned. It targets SSH, with state being 'what's installed on the machine', declaratively converging.

A typical production pipeline: Terraform runs apply, provisions 3 EC2 instances, and writes their IPs to a dynamic inventory script; Ansible takes over, running a playbook to install Nginx, copy config, and start the service on those 3 machines. Terraform builds, Ansible configures — each owns its segment, used together.

Why not all Terraform? Terraform's provisioners (local scripts / remote execution) are weak and discouraged, and it shouldn't manage the machine's internal state — whether some package is installed inside the machine isn't Terraform's business. Why not all Ansible? It can provision cloud resources (it has cloud-vendor modules), but its state management is nowhere near as clean as Terraform's state file; managing a machine's IP with Ansible is a disaster. Let each tool do its job: building machines is Terraform's, configuring machines is Ansible's.

A trend worth mentioning: the immutable infrastructure / Packer + Terraform approach is 'once a machine is provisioned, never modify it'; to update, build a new image and roll-replace, with Ansible running only once during the image-build phase. Under this model Ansible degenerates into an image factory, and ops no longer needs 'online convergence'. But it still doesn't fit small teams or stateful services (databases); online configuration management remains a hard requirement in most scenarios, and Ansible is still the default choice.

Summary

  1. The original sin of manual SSH ops is configuration drift: slow, unreproducible, inconsistent. Ansible describes the desired state in a declarative YAML file, converges over SSH, and cures the disease at its root.
  2. Five concepts are enough: inventory (which machines), playbook (what to do), task (one step), module (the unit that does the work), role (reusable unit). An extremely simple mental model.
  3. Idempotent + agentless are the foundations: idempotence lets you run repeatedly and auto-correct manual changes; agentless means if SSH works you can manage it, taking over any machine at zero cost. This is the root of Ansible's usability.
  4. Memorizing common modules beats digging through docs: apt/copy/template/service/file are the high-frequency five; command/shell is the last resort, and if you must, pair it with changed_when to reclaim idempotence.
  5. Production playbooks must carry validate checks and notify/handler triggers: validate config syntax before applying, and reload only on real changes — this is the essential difference from 'just SCP it and wing it'.
  6. Ansible and Terraform have a clear division of labor: Terraform builds machines (manages cloud APIs), Ansible configures machines (manages SSH); in a production pipeline they're used together, each doing its own job.

One sentence: the golden rule of ops automation — if a machine's state can't be described in a YAML file, it isn't truly under your control. What can be described in code can be reproduced, reviewed, and trusted; 'configuration' held together by human memory and chat logs will sooner or later come knocking as a late-night outage.

Related

Related Content