Ansible Automation: Server Configuration at Scale

Published 18 min read
ansibleautomationconfiguration-managementdevopsci-cd

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

ModulePurposeExample Use

`ansible.builtin.apt`Package management (Debian)Install nginx
`ansible.builtin.yum`Package management (RHEL)Install httpd
`ansible.builtin.template`Deploy Jinja2 templatesnginx.conf
`ansible.builtin.copy`Copy files to remoteSSL certificates
`ansible.builtin.service`Manage systemd servicesStart/restart nginx
`ansible.builtin.user`Manage OS usersCreate deploy user
`ansible.builtin.lineinfile`Edit single lines in filesUpdate /etc/hosts
`ansible.builtin.command`Run commands (not idempotent)One-off scripts
`ansible.builtin.shell`Run shell commandsCommands needing pipes
`ansible.builtin.wait_for`Wait for ports/conditionsPost-deploy health
`ansible.builtin.uri`HTTP requestsAPI 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.