I resisted Ansible for a while. My servers were small in number and I could remember what was on each one. Then I had to rebuild a server from scratch after a hardware failure, and I realized I had no idea exactly what I’d done to set it up. It took most of a day to get it back to where it was, mostly guessing based on what was running.
That was the moment I started using Ansible properly.
What Ansible Actually Is
Ansible is configuration management. You describe the desired state of your servers in YAML files (called playbooks), and Ansible makes it so. Run the same playbook twice and the second run changes nothing if the system is already in the right state. This idempotency is the key property that makes configuration management useful.
It connects over SSH. No agent to install on managed nodes, no daemon running on them. Just SSH and Python.
Installation
# On the control machine (your laptop or a management server)
pip install ansible
# or
apt install ansible
That’s it for the control side. Managed hosts just need SSH access and Python 3.
Inventory
Before running anything, tell Ansible what servers you have. The simplest inventory is a text file:
# inventory.ini
[webservers]
web1.example.com web2.example.com
[databases]
db1.example.com ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/deploy_key
[staging]
staging.example.com
Test connectivity:
ansible all -i inventory.ini -m ping
This SSHs to every host and returns pong if it works.
Your First Playbook
A playbook that installs and configures Nginx:
# nginx.yml
---
- name: Configure web servers
hosts: webservers
become: true # sudo
tasks:
- name: Install Nginx
apt:
name: nginx
state: present
update_cache: yes
- name: Ensure Nginx is running and enabled
service:
name: nginx
state: started
enabled: yes
- name: Copy Nginx config
template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
notify: Reload Nginx
handlers:
- name: Reload Nginx
service:
name: nginx
state: reloaded
Run it:
ansible-playbook -i inventory.ini nginx.yml
become: true — run tasks with sudo.notify: Reload Nginx — triggers the handler when the config template changes.
Handlers run at the end of a play and only when notified. This means Nginx reloads only when the config actually changes, not on every run.
Variables
Hard-coding values in playbooks gets messy. Use variables:
# group_vars/webservers.yml
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_server_name: example.com
Reference in playbooks or templates:
- name: Set worker processes
lineinfile:
path: /etc/nginx/nginx.conf
regexp: '^worker_processes'
line: "worker_processes {{ nginx_worker_processes }};"
Variables can live in:
group_vars/groupname.yml— applies to all hosts in a grouphost_vars/hostname.yml— host-specific variablesvars:block in a playbook--extra-varson the command line
Templates
Templates are Jinja2 files. Create templates/nginx.conf.j2:
worker_processes {{ nginx_worker_processes }};
events {
worker_connections {{ nginx_worker_connections }};
}
http {
server {
server_name {{ nginx_server_name }};
# ...
}
}
Ansible renders the template with variables and deploys it. Any time a variable changes, the template is regenerated and deployed.
Roles: Organizing Complex Playbooks
For anything beyond simple scripts, organize with roles. A role is a structured directory:
roles/
└── nginx/
├── tasks/
│ └── main.yml # Task list
├── handlers/
│ └── main.yml # Handlers
├── templates/
│ └── nginx.conf.j2 # Templates
├── vars/
│ └── main.yml # Role variables
└── defaults/
└── main.yml # Default variable values
Use roles in a playbook:
- name: Configure web servers
hosts: webservers
become: true
roles:
- nginx
- php-fpm
- certbot
Roles are reusable, composable, and shareable. Ansible Galaxy (galaxy.ansible.com) has thousands of community roles — I use them as starting points and customize for my setup.
Common Modules
The modules I use most:
# Package management
- apt:
name: "{{ packages }}"
state: present
vars:
packages:
- nginx
- php8.2-fpm
- postgresql
# File operations
- file:
path: /var/log/myapp
state: directory
owner: www-data
mode: '0755'
- copy:
src: files/myapp.conf
dest: /etc/myapp.conf
- template:
src: templates/myapp.conf.j2
dest: /etc/myapp.conf
# Service management
- service:
name: nginx
state: started
enabled: yes
# Run commands (when no module exists)
- command: /usr/local/bin/init-db.sh
args:
creates: /var/lib/myapp/.initialized # Only run if this file doesn't exist
# User management
- user:
name: deploy
groups: www-data
shell: /bin/bash
create_home: yes
# Add to authorized_keys
- authorized_key:
user: deploy
key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
Checking What Would Change (Dry Run)
ansible-playbook -i inventory.ini nginx.yml --check
--check mode shows what would change without making changes. Like rsync --dry-run. I run this before any significant playbook run on production.
Add --diff to see the actual content changes for files:
ansible-playbook -i inventory.ini nginx.yml --check --diff
Targeting Specific Hosts or Groups
# Run only on one host
ansible-playbook -i inventory.ini nginx.yml --limit web1.example.com
# Run only on staging
ansible-playbook -i inventory.ini nginx.yml --limit staging
# Run a specific tag
ansible-playbook -i inventory.ini nginx.yml --tags "config"
Tags let you skip or run specific tasks:
- name: Copy Nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
tags: config
A Realistic Server Setup Playbook
Here’s approximately what I run on new servers:
---
- name: Base server setup
hosts: all
become: true
vars:
deploy_user: deploy
ssh_port: 22
tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Upgrade packages
apt:
upgrade: safe
- name: Install base packages
apt:
name:
- vim
- git
- curl
- ufw
- fail2ban
- unattended-upgrades
state: present
- name: Create deploy user
user:
name: "{{ deploy_user }}"
groups: sudo
shell: /bin/bash
- name: Set up SSH keys for deploy user
authorized_key:
user: "{{ deploy_user }}"
key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
- name: Configure UFW defaults
ufw:
default: deny
direction: incoming
- name: Allow SSH
ufw:
rule: allow
port: "{{ ssh_port }}"
proto: tcp
- name: Enable UFW
ufw:
state: enabled
Running this against a new server takes 2–3 minutes and gives me a consistent baseline. Then I layer on application-specific roles.
Vault: Storing Secrets
Never put passwords or API keys in plaintext in playbooks. Use Ansible Vault:
# Encrypt a file
ansible-vault encrypt group_vars/all/secrets.yml
# Create encrypted file
ansible-vault create group_vars/all/secrets.yml
# Edit encrypted file
ansible-vault edit group_vars/all/secrets.yml
Run playbook with vault password:
ansible-playbook --ask-vault-pass playbook.yml
# or
ansible-playbook --vault-password-file ~/.vault_pass playbook.yml
The encrypted file is safe to commit to git — the vault password is not.
Ansible has become a core part of how I manage servers. The investment in writing playbooks pays off every time you need to rebuild a server, provision a new one, or audit what’s actually running where.