Server Configuration at Scale with Ansible
This covers how to structure Ansible inventories, roles, and playbooks so server configuration stays idempotent and reviewable, using an SSH-hardening and Nginx playbook as the running example, plus secrets encrypted with Vault.
Inventory: static hosts plus group_vars/host_vars
Resist the urge to put configuration values directly in playbooks. Inventory tells Ansible which hosts exist and how they're grouped; group_vars and host_vars tell it what's true about each group or host. That separation lets the same playbook run safely against staging and production.
# inventory/production.ini
[web]
web-01.example.internal
web-02.example.internal
[db]
db-01.example.internal
[web:vars]
nginx_worker_connections=2048
[production:children]
web
dbinventory/
├── production.ini
├── staging.ini
├── group_vars/
│ ├── all.yml
│ ├── web.yml
│ └── db.yml
└── host_vars/
└── db-01.example.internal.ymlgroup_vars/all.yml holds values true everywhere (NTP servers, the admin SSH key, log retention). group_vars/web.yml holds values true for every web node (Nginx tuning, the app port). host_vars/<hostname>.yml is for the genuine one-offs. And if that file starts accumulating more than a couple of keys, that's usually a sign the host doesn't belong in its current group.
Role layout: one responsibility per role
A role is the unit of reuse. Each one should do exactly one thing: "harden SSH" or "install and configure Nginx," not "set up a web server," which inevitably grows into an unstructured pile.
roles/
└── nginx/
├── tasks/
│ └── main.yml
├── handlers/
│ └── main.yml
├── templates/
│ └── nginx.conf.j2
├── defaults/
│ └── main.yml
└── files/
└── snakeoil-fallback.confdefaults/main.yml sets sane values that callers can override from group_vars. tasks/main.yml is the actual work. handlers/main.yml holds actions that fire only when something actually changed.
A real playbook: SSH hardening and Nginx
Here's a trimmed but functional example covering both patterns: locking down SSH and deploying Nginx from a template.
# playbooks/site.yml
---
- name: Harden SSH and configure web servers
hosts: web
become: true
roles:
- ssh_hardening
- nginx# roles/ssh_hardening/tasks/main.yml
---
- name: Disable password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
validate: '/usr/sbin/sshd -T -f %s'
notify: Restart sshd
- name: Disable root login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
validate: '/usr/sbin/sshd -T -f %s'
notify: Restart sshd# roles/ssh_hardening/handlers/main.yml
---
- name: Restart sshd
ansible.builtin.service:
name: sshd
state: restartedlineinfile with validate is doing real work here. It only rewrites the file when the line differs, and it refuses to save a config that would leave sshd unable to start. That matters when the resource you're editing is the one you SSH in through. notify queues the handler, but handlers only fire at the end of the play, and only once, no matter how many tasks notify them.
The Nginx role follows the same idea with a template instead of line edits:
# roles/nginx/tasks/main.yml
---
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Deploy nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
validate: 'nginx -t -c %s'
notify: Reload nginx
- name: Ensure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true# roles/nginx/templates/nginx.conf.j2
user www-data;
worker_processes auto;
events {
worker_connections {{ nginx_worker_connections }};
}
http {
server_tokens off;
keepalive_timeout {{ nginx_keepalive_timeout | default(65) }};
server {
listen 80;
server_name {{ inventory_hostname }};
root {{ nginx_document_root }};
location / {
try_files $uri $uri/ =404;
}
}
}template renders Jinja2 with the host's variables, compares the result against what's on disk, and reports a change (and fires notify) only when the rendered file actually differs. Run this playbook a second time with nothing changed upstream and every task should report ok, not changed. If it doesn't, something in that role isn't actually idempotent yet.
Reach for shell or command before checking for a proper module, and you lose idempotency by default. Ansible has no way to know whether shell: nginx -s reload needs to run again, so it runs every time and reports changed regardless of state. If you must use shell, pair it with creates, removes, or a changed_when condition so the task can honestly report whether it did anything.
Secrets: Ansible Vault in group_vars
Database passwords, API keys, and TLS private keys don't belong in plaintext YAML, even in a private repo. Vault solves this by encrypting values in place while keeping them addressable exactly like any other variable.
ansible-vault encrypt_string 'S3cr3tP@ss' --name 'db_password' \
>> inventory/group_vars/db/vault.ymlI keep a plain group_vars/db/vars.yml for non-secret values and a separate group_vars/db/vault.yml for encrypted ones. Ansible merges everything under group_vars/db/* automatically, so a role just references {{ db_password }} and doesn't care which file it came from. Running the playbook then just needs --ask-vault-pass, or --vault-password-file pointed at a file kept out of the repo entirely.
In CI, store the vault password as a pipeline secret and write it to a temporary file at job start (echo "$VAULT_PASSWORD" > /tmp/vault-pass), then pass --vault-password-file /tmp/vault-pass. Never echo the password into logs, and clean the file up in an after_script step: a leaked vault password decrypts every secret in the repo.
Tags and --check for safe rollout
On a fleet of any real size, you rarely want to run the entire playbook every time; you want to reload Nginx configs across the fleet without touching SSH settings, or vice versa. Tags give you that control without splitting the playbook into a dozen files.
- name: Deploy nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Reload nginx
tags:
- nginx
- config# only the nginx-tagged tasks
ansible-playbook -i inventory/production.ini playbooks/site.yml --tags nginx
# preview every change, apply nothing
ansible-playbook -i inventory/production.ini playbooks/site.yml --check --diff--check runs the playbook without making changes and reports what would happen; --diff adds the actual before/after text for anything template or copy would rewrite. I run every playbook with --check --diff against production first, until running it for real becomes routine for a client's team.
Migrating from ad hoc scripts to roles is a decision that's cheaper to make before the fleet hits fifty hosts than after.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out