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
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:
# 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:
- 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.
| Module | What it does | Typical usage |
|---|---|---|
apt / yum / dnf | Install packages (Debian / RHEL family) | name: nginx state: present |
copy | Copy a local file to the remote | src: nginx.conf dest: /etc/nginx/nginx.conf |
template | Render with Jinja2 then copy over | Same as copy, but src is a .j2 template |
service / systemd | Service management | name: nginx state: started enabled: yes |
file | Files/dirs/permissions/symlinks | path: /var/www state: directory mode: '0755' |
user / group | User and group management | name: deploy shell: /bin/bash |
git | Pull code | repo: ... dest: /srv/app version: main |
command / shell | Run arbitrary commands | Last resort, breaks idempotence |
debug | Print debug info | msg: "{{ var }}" |
assert | Assert, stops on failure | Validate 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.
---
# 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: runapt updatebefore installing, but don't re-refresh the cache within an hour, balancing correctness and speed.validate: 'nginx -t -c %s':%sis 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 waschanged, 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:
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:
{# 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;
}
}
# 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:
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:
- 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
- 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.
- 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.
- 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.
- Memorizing common modules beats digging through docs: apt/copy/template/service/file are the high-frequency five;
command/shellis the last resort, and if you must, pair it withchanged_whento reclaim idempotence. - Production playbooks must carry
validatechecks andnotify/handlertriggers: validate config syntax before applying, and reload only on real changes — this is the essential difference from 'just SCP it and wing it'. - 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
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.
Article
Long-Running Tool Calls: The Terminal-State Protocol for Operation, Poll, and Cancel
After the initial request times out, the tool may still be running server-side; issuing a cancel does not mean the side effects have stopped. This article establishes an operation identity, polling watermark, cancellation race handling, and a single terminal-state acceptance point for a single long-running tool call.
Article
Agent Memory Erasure Propagation: From User Forgetting Request to Verifiable Purge
Deleting one row in the primary table won't clear vectors, summaries, caches, or restored backups. This article uses a deletion state machine, generational tombstones, derived lineage, and end-to-end canary verification to truly forget.