Introduction
SSH-ing into five servers to run the same commands is tedious. SSH-ing into fifty is impossible. Ansible solves this: define your server state in YAML, and Ansible applies it across your entire fleet—idempotently, in parallel.
Unlike Puppet or Chef, Ansible is agentless. It connects over SSH, runs commands, and cleans up. No daemons, no agent updates, no certificate management.
This tutorial takes you from zero to managing production Linux servers with Ansible. You'll write playbooks, organize roles, and automate common tasks like web server setup, database configuration, and monitoring agent deployment.
Installation and Basics
Install Ansible on your control machine (your laptop or a bastion host):
# Ubuntu/Debian
sudo apt install ansible
# macOS
brew install ansible
# Verify
ansible --version
Define your inventory—the list of servers to manage:
# inventory.ini
[webservers]
web01 ansible_host=10.0.1.10 ansible_user=deploy
web02 ansible_host=10.0.1.11 ansible_user=deploy
[databases]
db01 ansible_host=10.0.2.10 ansible_user=deploy
[all:vars]
ansible_python_interpreter=/usr/bin/python3
Test connectivity:
ansible all -i inventory.ini -m ping
# web01 | SUCCESS => {"changed": false, "ping": "pong"}
Ad-Hoc Commands
Before writing playbooks, learn ad-hoc commands—quick, one-line operations:
# Check disk usage on all servers
ansible all -i inventory.ini -m shell -a "df -h /"
# Install a package
ansible webservers -i inventory.ini -m apt -a "name=nginx state=present" --become
# Restart a service
ansible webservers -i inventory.ini -m systemd -a "name=nginx state=restarted" --become
The --become flag runs commands with sudo.
Writing Your First Playbook
Playbooks are YAML files that define a series of tasks:
# setup-web-server.yml
---
- name: Configure web servers
hosts: webservers
become: yes
vars:
nginx_port: 80
app_domain: example.com
tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install required packages
apt:
name:
- nginx
- certbot
- python3-certbot-nginx
state: present
- name: Create web root directory
file:
path: /var/www/{{ app_domain }}
state: directory
owner: www-data
group: www-data
mode: '0755'
- name: Deploy Nginx configuration
template:
src: nginx.conf.j2
dest: /etc/nginx/sites-available/{{ app_domain }}
notify: restart nginx
- name: Enable site
file:
src: /etc/nginx/sites-available/{{ app_domain }}
dest: /etc/nginx/sites-enabled/{{ app_domain }}
state: link
notify: restart nginx
handlers:
- name: restart nginx
systemd:
name: nginx
state: restarted
Run it:
ansible-playbook -i inventory.ini setup-web-server.yml
Variables and Templates
Variables make playbooks reusable. Define them in multiple places with clear precedence:
# group_vars/webservers.yml
app_domain: example.com
nginx_worker_connections: 4096
enable_ssl: true
Templates use Jinja2 syntax:
# templates/nginx.conf.j2
server {
listen {{ nginx_port }};
server_name {{ app_domain }};
root /var/www/{{ app_domain }};
index index.html;
location / {
try_files $uri $uri/ =404;
}
{% if enable_ssl %}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/{{ app_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ app_domain }}/privkey.pem;
{% endif %}
}
Handlers
Handlers run only when notified—perfect for restarting services after config changes. They run once at the end of the play, even if notified multiple times.
Organizing with Roles
As your automation grows, split logic into roles:
ansible-galaxy init nginx
# Creates:
# nginx/
# tasks/main.yml
# handlers/main.yml
# templates/
# vars/main.yml
# defaults/main.yml
# meta/main.yml
Role structure:
# roles/nginx/tasks/main.yml
---
- name: Install Nginx
apt:
name: nginx
state: present
- name: Configure Nginx
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: restart nginx
- name: Ensure Nginx is running
systemd:
name: nginx
state: started
enabled: yes
Use roles in your playbook:
---
- name: Production servers
hosts: all
become: yes
roles:
- common
- nginx
- node_exporter
Real-World Example: Full Stack Setup
Here's a playbook that sets up a complete web application stack:
# site.yml
---
- name: Database tier
hosts: databases
become: yes
roles:
- role: postgresql
vars:
postgres_version: 16
databases:
- name: myapp
user: myapp
password: "{{ vault_db_password }}"
- name: Application tier
hosts: webservers
become: yes
roles:
- common
- nginx
- nodejs
tasks:
- name: Deploy application
git:
repo: https://github.com/your-org/myapp.git
dest: /opt/myapp
version: "{{ app_version }}"
- name: Install dependencies
npm:
path: /opt/myapp
production: yes
- name: Start application with PM2
command: pm2 start /opt/myapp/server.js --name myapp
Manage secrets with Ansible Vault:
ansible-vault create group_vars/all/vault.yml
# Enter and confirm password, then add:
# vault_db_password: superSecret123
ansible-playbook site.yml -i inventory.ini --ask-vault-pass
For CI/CD, store the vault password in your pipeline secrets and use --vault-password-file.
Roles: The Building Blocks of Ansible
Raw playbooks work for simple tasks. For real infrastructure management, use Ansible Roles to organize your automation into reusable components.
Role Directory Structure
ansible/
roles/
webserver/
tasks/main.yml # Main task list
handlers/main.yml # Restart/reload handlers
templates/nginx.conf.j2 # Jinja2 templates
vars/main.yml # Role variables (high priority)
defaults/main.yml # Default variables (low priority)
meta/main.yml # Role dependencies
Writing a Role
# roles/webserver/tasks/main.yml
---
- name: Install Nginx
apt:
name: nginx
state: present
update_cache: yes
become: yes
- name: Deploy Nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
notify: restart nginx
become: yes
# roles/webserver/handlers/main.yml
---
- name: restart nginx
service:
name: nginx
state: restarted
become: yes
Using Roles
# site.yml
---
- hosts: all
roles:
- common
- hosts: webservers
roles:
- webserver
vars:
nginx_port: 443
Run:
ansible-playbook -i inventory/production site.yml
Advanced Playbook Patterns
Idempotent User Management
- name: Manage system users
user:
name: "{{ item.name }}"
state: "{{ item.state | default('present') }}"
groups: "{{ item.groups | default([]) }}"
shell: "{{ item.shell | default('/bin/bash') }}"
loop: "{{ system_users }}"
become: yes
With inventory data:
# group_vars/all.yml
system_users:
- name: alice
groups: ['sudo', 'docker']
- name: bob
groups: ['dev']
- name: deploy-bot
state: absent
Rolling Updates with Serial
- name: Rolling Nginx update
hosts: webservers
serial: 2
max_fail_percentage: 25
tasks:
- name: Deploy new config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: restart nginx
- name: Wait for health check
uri:
url: "https://{{ inventory_hostname }}/health"
status_code: 200
register: result
until: result.status == 200
retries: 10
delay: 5
Integrating with AWX (Ansible Tower)
AWX provides a web UI, RBAC, and job scheduling:
version: '3.8'
services:
awx-web:
image: ansible/awx:latest
ports:
- "80:80"
depends_on:
- postgres
- redis
postgres:
image: postgres:15
environment:
POSTGRES_DB: awx
POSTGRES_USER: awx
redis:
image: redis:7
AWX Job Templates make playbooks reusable with different inventories and credentials:
| Field | Value |
|---|---|
| Name | Deploy Web Servers |
| Inventory | Production |
| Project | ansible-infra |
| Playbook | site.yml |
| Limit | webservers |
Schedule via cron, trigger via webhook on git push, or run on-demand via the API.
Ansible vs Other Tools
| Feature | Ansible | Puppet | Chef | SaltStack |
|---|---|---|---|---|
| Architecture | Agentless (SSH) | Agent-based | Agent-based | Hybrid |
| Language | YAML | Ruby DSL | Ruby DSL | YAML/Python |
| Learning curve | Low | Medium | High | Medium |
| Idempotent | Yes | Yes | Yes | Yes |
| Cloud modules | Extensive | Moderate | Moderate | Good |
| Community | Largest | Large | Medium | Medium |
Ansible's agentless architecture is its killer feature. No agents to install, update, or monitor. If SSH works, Ansible works. This alone saves countless hours of agent management across fleets of thousands of servers.
Dynamic Inventory for Cloud Environments
Static inventory files don't scale across hundreds of servers. Use dynamic inventory scripts to pull host data from cloud APIs:
ansible-inventory -i aws_ec2.yaml --graph
ansible-playbook -i aws_ec2.yaml site.yml
# aws_ec2.yaml
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
- us-west-2
keyed_groups:
- key: tags.Environment
prefix: env
- key: tags.Role
prefix: role
hostnames:
- dns-name
- private-dns-name
compose:
ansible_host: private_ip_address
Now target groups dynamically across regions:
# Target all production web servers in both regions
ansible-playbook -i aws_ec2.yaml site.yml --limit 'env_production:&role_webserver'
Ansible Pull for Edge Devices
For IoT devices, edge nodes, or firewalled networks where push-based SSH doesn't work, use ansible-pull:
# Run on each node via cron job
ansible-pull -U https://github.com/company/ansible-infra.git \
-i localhost, \
site.yml
The node pulls the latest playbook from git and applies itself. This gives centralized control on devices that can't be reached via SSH.
Applying Ansible to Linux Server Management
Base Server Setup Playbook
- name: Base server configuration
hosts: all
become: yes
tasks:
- name: Set timezone
timezone:
name: UTC
- name: Install common packages
apt:
name:
- htop
- fail2ban
- ufw
- unattended-upgrades
state: present
- name: Configure firewall
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: [22, 80, 443]
- name: Harden SSH config
lineinfile:
path: /etc/ssh/sshd_config
regexp: "^{{ item.key }}"
line: "{{ item.key }} {{ item.value }}"
loop:
- {key: "PermitRootLogin", value: "no"}
- {key: "MaxAuthTries", value: "3"}
notify: restart sshd
Multi-Tier Deployment Example
A complete deployment covering load balancers, app servers, and databases:
- hosts: loadbalancers
roles:
- haproxy
vars:
haproxy_backend_servers: "{{ groups.appservers }}"
- hosts: appservers
roles:
- nginx
- nodejs-app
vars:
app_env: production
app_port: 3000
serial: 3
- hosts: databases
roles:
- postgresql
vars:
pg_version: 16
pg_max_connections: 200
Run the full stack:
ansible-playbook deploy.yml -i production -v
Ansible handles the ordering: load balancers first, then app servers (rolling, 3 at a time), then databases.
Ansible Best Practices Cheat Sheet
| Practice | Why It Matters |
|---|---|
| Use roles for everything | Reusability and readability |
| Keep playbooks short | Playbooks should orchestrate roles, not contain tasks |
Use --check before --diff | Dry-run mode catches errors before they hit servers |
| Pin Ansible and collection versions | Prevents breaking changes in CI/CD |
| Use ansible-lint in CI | Enforces best practices automatically |
| Structure inventory by environment | Prevents accidental production changes |
Use serial for rolling updates | Zero-downtime deployments |
Conclusion
Ansible turns server management from a manual, error-prone process into reproducible code. Start with ad-hoc commands, graduate to playbooks, then organize with roles as complexity grows.
The real power isn't in the tool—it's in the mindset. Every server configuration becomes version-controlled, testable, and repeatable. No more "it works on my machine" because every machine is built the same way.