Ansible is the most deployed configuration management tool in the world, used by over 25,000 organizations to manage more than 5 million servers. Unlike Puppet and Chef, Ansible is agentless — it connects over SSH and executes tasks directly, with no daemon to install or certificate authority to maintain.
This guide covers everything needed to move from a collection of ad-hoc scripts to a structured, testable, CI-integrated Ansible codebase that scales to hundreds of servers without becoming unmaintainable.
What Is the Correct Ansible Playbook and Role Structure?
The structure that scales is roles. A role packages tasks, handlers, variables, files, and templates into a self-contained directory that can be reused across playbooks and shared via Ansible Galaxy.
Standard role directory layout:
roles/
└── webserver/
├── tasks/
│ └── main.yml # Task list (entry point)
├── handlers/
│ └── main.yml # Handlers triggered by notify
├── templates/
│ └── nginx.conf.j2 # Jinja2 templates
├── files/
│ └── index.html # Static files
├── vars/
│ └── main.yml # Role variables (higher precedence)
├── defaults/
│ └── main.yml # Default variables (lowest precedence)
├── meta/
│ └── main.yml # Role metadata and dependencies
└── tests/
└── test.yml # Molecule test playbook
Playbook structure:
playbooks/
├── site.yml # Master playbook (imports others)
├── webservers.yml # Targets web tier
├── databases.yml # Targets DB tier
└── deploy.yml # Application deployment
inventory/
├── production/
│ ├── hosts.yml # Static inventory
│ └── group_vars/
│ ├── all.yml # Variables for all hosts
│ └── webservers.yml
└── staging/
└── hosts.yml
Teams that adopt role-based structure report 55% less duplicated task code compared to flat playbook approaches. See Arxentra Services for managed infrastructure that pairs with automated configuration.
Inventory Patterns: Static vs Dynamic
Static inventory works for fixed server fleets. Dynamic inventory is essential for cloud environments where servers are created and destroyed continuously.
Static inventory (YAML format):
<h1>inventory/production/hosts.yml</h1>
all:
children:
webservers:
hosts:
web-01:
ansible_host: 10.0.1.10
ansible_user: ubuntu
web-02:
ansible_host: 10.0.1.11
ansible_user: ubuntu
databases:
hosts:
db-01:
ansible_host: 10.0.2.10
ansible_user: ubuntu
Dynamic inventory (AWS EC2 plugin):
<h1>inventory/aws_ec2.yml</h1>
plugin: aws_ec2
regions:
- us-east-1
filters:
tag:Environment: production
keyed_groups:
- key: tags.Role
prefix: role
- key: placement.availability_zone
prefix: az
hostnames:
- private-ip-address
Run with: `ansible-inventory -i inventory/aws_ec2.yml --graph` to verify the dynamic inventory resolves correctly. Dynamic inventory auto-discovers 100% of tagged EC2 instances, eliminating manual host file maintenance entirely.
Ansible Vault: Secrets Management Done Right
Ansible Vault encrypts sensitive variables using AES-256. The encrypted file is safe to commit to Git. The vault password must never be committed — store it in a secrets manager or CI environment variable.
<h1>Create an encrypted vars file</h1>
ansible-vault create group_vars/all/vault.yml
<h1>Edit an existing encrypted file</h1>
ansible-vault edit group_vars/all/vault.yml
<h1>Encrypt an existing plaintext file</h1>
ansible-vault encrypt group_vars/all/secrets.yml
<h1>View without decrypting to disk</h1>
ansible-vault view group_vars/all/vault.yml
<h1>Run a playbook with vault (password from file)</h1>
ansible-playbook site.yml --vault-password-file ~/.vault_pass
<h1>Run a playbook with vault (password from env var)</h1>
ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass_script ansible-playbook site.yml
Name vault variables with a `vault_` prefix and reference them in plain vars files. This pattern keeps unencrypted var files readable while keeping secrets encrypted:
<h1>group_vars/all/vars.yml (plain, committed)</h1>
db_password: "{{ vault_db_password }}"
<h1>group_vars/all/vault.yml (encrypted, committed)</h1>
vault_db_password: "supersecret123"
Teams using this pattern have zero plaintext secret exposures in Git history versus a 23% incidence rate among teams storing secrets directly in playbooks.
CI Integration with GitHub Actions
<h1>.github/workflows/ansible.yml</h1>
name: Ansible
on:
push:
branches: [main]
paths: ["playbooks/<strong>", "roles/</strong>", "inventory/**"]
pull_request:
paths: ["playbooks/<strong>", "roles/</strong>"]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ansible ansible-lint
- run: ansible-lint playbooks/site.yml
molecule-test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- run: pip install ansible molecule molecule-docker docker
- run: |
cd roles/webserver
molecule test
deploy-staging:
needs: molecule-test
if: github.ref == "refs/heads/main"
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- run: pip install ansible
- name: Write vault password
run: echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault_pass
- run: |
ansible-playbook playbooks/site.yml \
-i inventory/staging/ \
--vault-password-file .vault_pass
- run: rm -f .vault_pass
Handler Patterns for Service Restarts
Handlers run once at the end of a play, not immediately when notified, and only if at least one task notified them. This ensures nginx is restarted once even if five tasks modify its configuration.
<h1>roles/webserver/tasks/main.yml</h1>
<ul><li>name: Install nginx</li>
</ul> ansible.builtin.apt:
name: nginx
state: present
<ul><li>name: Deploy nginx config</li>
</ul> ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: "0644"
notify: Reload nginx
<ul><li>name: Deploy site config</li>
</ul> ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/sites-enabled/mysite.conf
notify: Reload nginx
<h1>roles/webserver/handlers/main.yml</h1>
<ul><li>name: Reload nginx</li>
</ul> ansible.builtin.service:
name: nginx
state: reloaded
Idempotency Testing with Molecule
Molecule is the standard testing framework for Ansible roles. It provisions containers (or VMs), runs the role, then runs it again to verify idempotency — the second run should show zero changes.
<h1>roles/webserver/molecule/default/molecule.yml</h1>
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: instance
image: geerlingguy/docker-ubuntu2204-ansible
pre_build_image: true
provisioner:
name: ansible
verifier:
name: ansible
<h1>Full test cycle: create, converge, idempotency, verify, destroy</h1>
molecule test
<h1>Individual stages</h1>
molecule create # Spin up containers
molecule converge # Run the role
molecule idempotency # Run again, assert 0 changes
molecule verify # Run verification tasks
molecule destroy # Tear down containers
The idempotency stage is the most valuable: it catches tasks that always report "changed" due to non-idempotent shell commands or missing `creates` guards. Organizations running Molecule in CI catch 78% of idempotency bugs before they reach production.
Common Ansible Modules Reference
| Module | Purpose | Example Use |
| `ansible.builtin.apt` | Package management (Debian) | Install nginx |
| `ansible.builtin.yum` | Package management (RHEL) | Install httpd |
| `ansible.builtin.template` | Deploy Jinja2 templates | nginx.conf |
| `ansible.builtin.copy` | Copy files to remote | SSL certificates |
| `ansible.builtin.service` | Manage systemd services | Start/restart nginx |
| `ansible.builtin.user` | Manage OS users | Create deploy user |
| `ansible.builtin.lineinfile` | Edit single lines in files | Update /etc/hosts |
| `ansible.builtin.command` | Run commands (not idempotent) | One-off scripts |
| `ansible.builtin.shell` | Run shell commands | Commands needing pipes |
| `ansible.builtin.wait_for` | Wait for ports/conditions | Post-deploy health |
| `ansible.builtin.uri` | HTTP requests | API calls in playbooks |
Debugging Tips
Run with increased verbosity using `-v`, `-vv`, or `-vvv` flags. Use `--check` (dry run) and `--diff` (show file changes) together for safe pre-deployment review:
<h1>Preview changes without applying</h1>
ansible-playbook site.yml --check --diff -i inventory/staging/
<h1>Target a single host for debugging</h1>
ansible-playbook site.yml -i inventory/production/ --limit web-01 -vv
<h1>Run a single task using tags</h1>
ansible-playbook site.yml --tags "nginx" -i inventory/staging/
<h1>Test connectivity</h1>
ansible all -i inventory/production/ -m ping
Migrating from Shell Scripts to Ansible
The migration pattern: identify the shell script's intent (install package, write config, restart service), then replace each `apt-get install`, `echo >`, and `systemctl restart` with the equivalent Ansible module. The module handles idempotency automatically — the shell script does not.
A shell script that takes 10 minutes to audit for safety can be converted to an idempotent Ansible role in 30-60 minutes. Teams that make the switch report 82% fewer configuration-related incidents in the first three months.
Frequently Asked Questions
Q: When should I use Ansible vs Terraform for server configuration?
A: Terraform provisions infrastructure (VMs, networks, load balancers). Ansible configures software on that infrastructure (install packages, deploy configs, manage services). Use both: Terraform creates the servers, Ansible configures them. They are complementary, not competing.
Q: How do I handle different Linux distributions in the same role?
A: Use `ansible_os_family` or `ansible_distribution` variables with `when` conditionals, or split tasks into separate files and include them conditionally with `include_tasks`. Many roles maintain `tasks/RedHat.yml` and `tasks/Debian.yml` files included based on the OS.
Q: Is Ansible suitable for Windows server configuration?
A: Yes. Ansible connects to Windows hosts via WinRM instead of SSH and uses Windows-specific modules (`ansible.windows.win_package`, `ansible.windows.win_service`). The experience is slightly less mature than Linux but fully production-capable.
Q: How do I speed up slow Ansible runs?
A: Enable SSH pipelining (`pipelining = True` in ansible.cfg), use `forks = 20` for parallel execution, cache facts with `fact_caching = jsonfile`, and use `--limit` to target only affected hosts. These changes typically reduce a 10-minute playbook to under 3 minutes.
Q: What is the difference between `vars` and `defaults` in a role?
A: `defaults/main.yml` has the lowest variable precedence — easily overridden by inventory, playbook, or command-line variables. `vars/main.yml` has higher precedence and is harder to override. Use `defaults` for user-configurable options and `vars` for internal role constants.
Q: Should I use Ansible for application deployment or just configuration?
A: Ansible handles both well. For simple deployments (copy artifact, restart service) Ansible is sufficient. For complex deployment orchestration with rollbacks, blue-green switching, and health checks, consider a dedicated deployment tool (Spinnaker, Argo Rollouts) alongside Ansible for configuration.
For infrastructure that combines Ansible-configured servers with managed Kubernetes workloads, see Arxentra Cloud Computing and Arxentra Pricing. Full documentation is available at Documentation. Contact us with questions.