Automate three servers with Ansible: roles, templates, Vault and safe dry runs
Build a real Ansible project for two web servers and one database server: inventory groups, SSH keys, roles, templates, handlers, an encrypted secret, check mode, tags and an idempotency test.
Not checked by a trusted editor yet Last edited Outdated?
In this tutorial you turn three freshly installed Linux servers into a small, repeatable setup managed from one machine. Two servers run nginx as a reverse proxy, one runs PostgreSQL with a nightly backup job, and all three share a common baseline.
It is for administrators who have run a few Ansible commands and now want a project layout that still makes sense after six months: roles instead of one long playbook, variables per group, a secret that is safe to commit, and a habit of previewing changes before applying them.
What you will build
- A control node with
ansible-coreinstalled in a Python virtual environment. - An inventory with the groups
web(web1, web2),db(db1) and a parent grouplab. - A dedicated SSH key that lets Ansible log in without passwords.
- Three roles:
common(tools, time sync, login banner),web(nginx site plus an app settings file) anddb(PostgreSQL plus a backup script and cron job). - Templates with handlers that reload nginx only when its configuration really changed.
- A database password encrypted with Ansible Vault, used by the web servers.
- A workflow you can reuse: dry run with
--check --diff, apply, run only some tags, then prove idempotency with a second run that reportschanged=0.
Before you start
- Control node: a Linux or macOS machine (or WSL on Windows) with Python 3 and
venv. Native Windows is not supported as a control node. - Managed nodes: three Debian or Ubuntu servers reachable over SSH, with Python 3 installed (it is on standard images). This tutorial uses web1 192.0.2.11, web2 192.0.2.12 and db1 192.0.2.21; replace them with your addresses. Virtual machines on a laptop or a Proxmox host work fine.
- A user with sudo on each server, here called
admin. - Time: about 90 minutes, a bit more if you also create the virtual machines.
- Knowledge: basic shell and SSH, reading YAML, and the Ansible basics: what inventory, modules, playbooks and idempotency mean. If those are new, read an Ansible concepts article first.
Step 1: Install Ansible on the control node
Ansible only needs to be installed on the machine you run it from. A virtual environment keeps it separate from the system Python, and each project can pin its own version.
mkdir -p ~/infra && cd ~/infra
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install ansible-core
ansible --version
The first line of the output shows the version, for example ansible [core 2.x.y]. ansible-core contains the engine and the ansible.builtin modules used here. If you later need community collections, install the bigger ansible package instead, or add single collections with ansible-galaxy collection install.
Run source .venv/bin/activate again in every new terminal before using Ansible.
Step 2: Create an SSH key and copy it to the servers
Ansible logs in over SSH like a person would. A dedicated key for automation is easier to rotate or revoke than your personal key.
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
ssh-keygen -t ed25519 -f ~/.ssh/ansible_lab -C "ansible lab"
for ip in 192.0.2.11 192.0.2.12 192.0.2.21; do
ssh-copy-id -i ~/.ssh/ansible_lab.pub admin@$ip
done
ssh -i ~/.ssh/ansible_lab [email protected] 'hostname && sudo -n true && echo sudo-ok'
ssh-copy-id asks for the admin password once per server. The last command should print the host name, and sudo-ok if passwordless sudo is configured. If it prints a sudo error instead, you will pass -K (ask for the become password) to the commands below.
Connect once by hand to each server as shown, so its host key lands in ~/.ssh/known_hosts. The project keeps host key checking on.
Step 3: Write the inventory and the project config
The inventory lists the servers and puts them into groups. Groups are what you target in plays and what you attach variables to. The lab:children section creates a parent group that contains both other groups.
[web]
web1 ansible_host=192.0.2.11
web2 ansible_host=192.0.2.12
[db]
db1 ansible_host=192.0.2.21
[lab:children]
web
db
Save it as inventory/hosts.ini. Then create ansible.cfg in the project root, so you do not have to repeat the same options on every command:
[defaults]
inventory = inventory/hosts.ini
remote_user = admin
host_key_checking = True
retry_files_enabled = False
private_key_file = ~/.ssh/ansible_lab
vault_password_file = ~/.ansible/lab_vault_pass
Ansible reads ansible.cfg from the current directory, so always run commands from ~/infra. The vault password file is created in Step 7; until then, commands still work as long as nothing encrypted is loaded. Check what Ansible sees:
ansible-inventory --graph
ansible-config dump --only-changed
@all:
|--@ungrouped:
|--@lab:
| |--@web:
| | |--web1
| | |--web2
| |--@db:
| | |--db1
Step 4: Run ad-hoc checks
Before writing playbooks, confirm that Ansible can reach every host, run Python there and become root. Ad-hoc commands run one module against a group without a playbook.
ansible lab -m ansible.builtin.ping
ansible lab -a "uptime"
ansible lab -m ansible.builtin.setup -a "filter=ansible_distribution*"
ansible lab -b -a "whoami"
Every host should answer "ping": "pong"; this proves SSH login and Python, it is not an ICMP ping. The setup call shows the distribution and version Ansible detected, which matters because this project uses the apt module. The last line uses -b (become) and must print root for every host; add -K if sudo asks for a password.
Step 5: Lay out the project with roles
A role is a folder with a fixed structure: tasks, handlers, templates and default variables that belong together. The playbook then only says which roles apply to which group. This is the layout recommended in the Ansible sample setup, trimmed to what this project needs.
mkdir -p group_vars/{all,web,db}
mkdir -p roles/common/{tasks,templates,defaults}
mkdir -p roles/web/{tasks,handlers,templates,defaults}
mkdir -p roles/db/{tasks,templates,defaults}
infra/
ansible.cfg
site.yml
inventory/hosts.ini
group_vars/
all/vars.yml
web/vars.yml
web/vault.yml (encrypted)
db/vars.yml
roles/
common/ defaults/ tasks/ templates/
web/ defaults/ handlers/ tasks/ templates/
db/ defaults/ tasks/ templates/
The top-level playbook site.yml maps groups to roles. Running it configures everything; later you can limit it to one group or one host.
- name: Baseline for every server
hosts: lab
become: true
roles:
- common
- name: Web servers
hosts: web
become: true
roles:
- web
- name: Database servers
hosts: db
become: true
roles:
- db
Step 6: Write the roles
Start with the common role. Its defaults are the lowest-priority values, so a role works on its own but group variables can override them.
# roles/common/defaults/main.yml
admin_packages: []
motd_owner: "IT"
# roles/common/tasks/main.yml
- name: Update apt cache if older than one hour
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
tags: [packages]
- name: Install admin tools
ansible.builtin.apt:
name: "{{ admin_packages }}"
state: present
tags: [packages]
- name: Install chrony for time sync
ansible.builtin.apt:
name: chrony
state: present
tags: [packages, time]
- name: Make sure chrony is running and enabled
ansible.builtin.service:
name: chrony
state: started
enabled: true
tags: [time]
- name: Write message of the day
ansible.builtin.template:
src: motd.j2
dest: /etc/motd
owner: root
group: root
mode: "0644"
tags: [motd]
{# roles/common/templates/motd.j2 #}
{{ ansible_managed }}
Host: {{ inventory_hostname }}
Groups: {{ group_names | join(', ') }}
Owner: {{ motd_owner }}
Templates are Jinja2 files. ansible_managed renders a marker line that warns people not to edit the file by hand, inventory_hostname is the name from the inventory and group_names lists the host’s groups.
The web role installs nginx, renders a site file, links it into sites-enabled and writes an app settings file that contains the database password. Two tasks notify the same handler.
# roles/web/tasks/main.yml
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
tags: [packages, nginx]
- name: Deploy the site configuration
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/sites-available/{{ site_name }}.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
tags: [nginx]
- name: Enable the site
ansible.builtin.file:
src: /etc/nginx/sites-available/{{ site_name }}.conf
dest: /etc/nginx/sites-enabled/{{ site_name }}.conf
state: link
notify: Reload nginx
tags: [nginx]
- name: Create the app config directory
ansible.builtin.file:
path: /etc/myapp
state: directory
owner: root
group: root
mode: "0750"
tags: [app]
- name: Write app settings with the database password
ansible.builtin.template:
src: app.env.j2
dest: /etc/myapp/app.env
owner: root
group: root
mode: "0600"
no_log: true
diff: false
tags: [app]
- name: Make sure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
tags: [nginx]
# roles/web/handlers/main.yml
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
# roles/web/templates/site.conf.j2
# {{ ansible_managed }}
server {
listen 80;
server_name {{ site_name }};
location / {
proxy_pass http://127.0.0.1:{{ app_port }};
proxy_set_header Host $host;
}
}
{# roles/web/templates/app.env.j2 #}
DB_HOST={{ db_host }}
DB_USER=appuser
DB_PASSWORD={{ db_password }}
# roles/web/defaults/main.yml
site_name: "example.test"
app_port: 8080
A handler runs once at the end of the play, and only if at least one task that notifies it reported changed. If the site file is already correct, nginx is not touched. no_log: true and diff: false keep the password out of the console output and out of --diff.
The db role installs PostgreSQL and a small backup script scheduled with cron:
# roles/db/tasks/main.yml
- name: Install PostgreSQL
ansible.builtin.apt:
name: postgresql
state: present
tags: [packages, postgres]
- name: Make sure PostgreSQL is running and enabled
ansible.builtin.service:
name: postgresql
state: started
enabled: true
tags: [postgres]
- name: Create the backup directory
ansible.builtin.file:
path: "{{ backup_dir }}"
state: directory
owner: postgres
group: postgres
mode: "0750"
tags: [backup]
- name: Install the backup script
ansible.builtin.template:
src: pg-backup.sh.j2
dest: /usr/local/bin/pg-backup.sh
owner: root
group: root
mode: "0755"
tags: [backup]
- name: Schedule the nightly backup
ansible.builtin.cron:
name: "nightly pg_dumpall"
user: postgres
minute: "30"
hour: "2"
job: /usr/local/bin/pg-backup.sh
tags: [backup]
{# roles/db/templates/pg-backup.sh.j2 #}
#!/bin/bash
# {{ ansible_managed }}
set -euo pipefail
pg_dumpall | gzip > "{{ backup_dir }}/all-$(date +%F).sql.gz"
find "{{ backup_dir }}" -name 'all-*.sql.gz' -mtime +{{ backup_keep_days }} -delete
# roles/db/defaults/main.yml
backup_dir: /var/backups/postgresql
backup_keep_days: 7
Step 7: Set group variables and encrypt the secret
Files under group_vars/GROUPNAME/ apply to every host in that group and override role defaults. Here all holds the baseline, web the site settings and db a longer backup retention than the role default.
# group_vars/all/vars.yml
admin_packages:
- curl
- vim
- htop
motd_owner: "Platform team"
# group_vars/web/vars.yml
site_name: "lab.example.com"
app_port: 8080
db_host: "192.0.2.21"
db_password: "{{ vault_db_password }}"
# group_vars/db/vars.yml
backup_dir: /var/backups/postgresql
backup_keep_days: 14
db_password points to vault_db_password, which lives in an encrypted file next to it. This pattern keeps variable names searchable in plain text while the values stay encrypted. Create a vault password file outside the project, then create and encrypt the secret:
mkdir -p ~/.ansible && chmod 700 ~/.ansible
openssl rand -base64 32 > ~/.ansible/lab_vault_pass
chmod 600 ~/.ansible/lab_vault_pass
printf 'vault_db_password: "CHANGE_ME"\n' > group_vars/web/vault.yml
ansible-vault encrypt group_vars/web/vault.yml
head -1 group_vars/web/vault.yml
ansible-vault view group_vars/web/vault.yml
The encrypted file starts with $ANSIBLE_VAULT;1.1;AES256. Replace CHANGE_ME later with ansible-vault edit group_vars/web/vault.yml, which decrypts into an editor and re-encrypts on save. Because ansible.cfg points to the password file, you do not need --ask-vault-pass. Never commit the password file itself.
Check the resolved value without running a play:
ansible web1 -m ansible.builtin.debug -a var=db_password
Step 8: Dry run with check mode and diff
Check mode simulates the run and reports what would change, without changing the servers. Diff mode shows the content changes of template and file tasks. Start with one host, because diffs get long.
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --check --diff --limit web1
For the motd template you should see a diff like this, followed by changed for that task:
--- before
+++ after: .../motd.j2
@@ -0,0 +1,4 @@
+Ansible managed
+Host: web1
+Groups: lab, web
+Owner: Platform team
Check mode is only a simulation. On a brand-new server some tasks can fail in check mode because they depend on an earlier task that was only simulated: for example, starting the nginx service when the package was not really installed. That is expected on the first run. To make such a task check-mode friendly, the docs suggest the ansible_check_mode variable, for example ignore_errors: "{{ ansible_check_mode }}".
Step 9: Apply the playbook
Run the real thing for all hosts:
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
ansible-playbook site.yml
# if sudo asks for a password:
ansible-playbook site.yml -K
The PLAY RECAP at the end lists each host with counters such as ok, changed, failed and unreachable. On the first run expect many changed tasks and the handler Reload nginx running on web1 and web2. failed=0 and unreachable=0 for all three hosts is what you want.
nginx itself is not validated by Ansible in this project, so test the configuration explicitly:
ansible web -b -a "nginx -t"
Step 10: Run only some tags
Every task carries tags such as packages, nginx, app or backup. Tags let you run a slice of the project, which is much faster than a full run when you only changed one thing.
ansible-playbook site.yml --list-tags
ansible-playbook site.yml --list-tasks --tags backup
ansible-playbook site.yml --tags nginx --check --diff
ansible-playbook site.yml --tags nginx
ansible-playbook site.yml --skip-tags packages
--list-tasks --tags backup should show only the three backup tasks of the db role. If you pass both --tags and --skip-tags, skipping wins.
Try a real change: set app_port: 9000 in group_vars/web/vars.yml and run with --tags nginx --diff. Only the site file changes, and the handler reloads nginx on both web servers.
Step 11: Prove idempotency
A well-written playbook can run any number of times; once the servers match the description, nothing changes. Run the full playbook twice in a row:
ansible-playbook site.yml
ansible-playbook site.yml | tail -5
PLAY RECAP *********************************************************
db1 : ok=... changed=0 unreachable=0 failed=0 ...
web1 : ok=... changed=0 unreachable=0 failed=0 ...
web2 : ok=... changed=0 unreachable=0 failed=0 ...
The exact ok counts depend on your play, but every host should report changed=0 on the second run, and the handler should not run. If a task reports changed every time, it is not idempotent: usually a command or shell task without creates or changed_when, or a template with a timestamp in it.
Check that it works
From the control node, test each layer independently of Ansible:
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
curl -sI -H 'Host: lab.example.com' http://192.0.2.11/ | head -1
ssh -i ~/.ssh/ansible_lab [email protected] 'systemctl is-active postgresql; sudo crontab -l -u postgres'
ansible web -b -a "stat -c '%a %U' /etc/myapp/app.env"
ansible lab -a "cat /etc/motd"
- The web servers answer HTTP for the site name. Because no app is listening on the backend port yet, nginx returns a
502 Bad Gatewaystatus line: that still proves nginx is running with your site. Without theHostheader, the distribution’s default site would answer instead. - PostgreSQL is
activeand the postgres user’s crontab contains thenightly pg_dumpallentry. /etc/myapp/app.envis mode600and owned by root.- Each server’s message of the day shows its own name and groups.
- A second
ansible-playbook site.ymlreportschanged=0everywhere.
If something goes wrong
- Host shows UNREACHABLE. Ansible could not log in over SSH. Test with
ssh -i ~/.ssh/ansible_lab admin@IP, checkremote_userandprivate_key_fileinansible.cfg, and run with-vvvto see the SSH command used. - Attempting to decrypt but no vault secrets found. A vaulted file was loaded but Ansible has no vault password. Check that
vault_password_fileinansible.cfgpoints to an existing file, or pass--ask-vault-pass. Note that--syntax-checkdoes not load group variables, so it can pass while a real run fails here. - Missing sudo password. Tasks with
become: trueneed root. Add-Kor configure passwordless sudo for the automation user. - Your ansible.cfg is ignored. Ansible reads it from the current directory, and skips it if that directory is world-writable. Run
ansible-config dump --only-changedfrom the project root to see which file is used. - A variable has an unexpected value. Run
ansible-inventory --host web1to see the merged host and group variables, and remember thatgroup_varsoverride roledefaults. - Check mode fails on a new server at a service task. The package was only simulated in the earlier task, so the service does not exist yet. Apply once, or make the task tolerant with
ansible_check_modeas shown in Step 8.
Clean up
To stop managing the servers, simply stop running Ansible; nothing is installed on them for Ansible itself. To remove what this project installed, write the reverse as a play (for example state: absent for the packages, files and cron job) and preview it with --check --diff first.
Warning: removing the PostgreSQL package does not by itself remove data, but purging it or deleting /var/lib/postgresql or the backup directory destroys the databases and backups. Check what is there with sudo -u postgres psql -l and ls /var/backups/postgresql before removing anything.
ssh -i ~/.ssh/ansible_lab [email protected] 'sed -i "/ansible lab$/d" ~/.ssh/authorized_keys'
rm ~/.ssh/ansible_lab ~/.ssh/ansible_lab.pub
deactivate && rm -r ~/infra/.venv
The first line removes the automation key from one server; repeat it for each address before deleting the local key, or you lock Ansible out without cleaning up.
Next steps
- Put the project into Git with a
.gitignorefor.venv/, and runansible-playbook site.yml --syntax-checkin CI on every merge request. - Add
ansible-lintto catch non-idempotent tasks and deprecated syntax early. - Split the inventory into
inventory/stagingandinventory/productionand choose one with-i. - Replace the static IPs with a dynamic inventory plugin for your hypervisor or cloud.
- Use a PostgreSQL collection from Ansible Galaxy to create databases and users instead of doing it by hand.
Official documentation
See also
- 0053 Set up SSH keys: ssh-keygen, ssh-copy-id and ~/.ssh/config SSH keys let you log in to servers and push to Git hosts without typing a password. Create an ed25519 key, install… Beginner · How-to · Linux, macOS, Windows Article Keys & config 15 Oct 2026
- 0054 Fix: Permission denied (publickey) The server refused every key your SSH client offered. Find out which key was offered, make sure its public half is on… Intermediate · Fix · Linux, macOS, Windows Article Keys & config 15 Oct 2026
- 0178 Create a sudo user with an SSH key and disable password login A bash script that creates an admin user in the sudo or wheel group, installs their SSH public key and can switch… Advanced · How-to · Linux Script Keys & config · Users, sudo & hardening 15 Oct 2026
- 0181 Ansible concepts: inventory, modules, playbooks, idempotency and roles Ansible configures many servers over SSH from one control machine. Learn the core pieces: inventory, modules, tasks, playbooks, roles, and why runs… Beginner · Concept · Linux, macOS Article Ansible 15 Oct 2026
- 0182 Your first Ansible playbook: install nginx and publish a page A complete beginner playbook that installs nginx on Debian or Ubuntu servers, starts it at boot and deploys a home page, with… Beginner · How-to · Linux, macOS Script Ansible 15 Oct 2026
- 0183 Ansible variables, templates and handlers: configure an nginx site Use group variables, Jinja2 templates and handlers together: render an nginx server block from variables, test the config and reload nginx only… Intermediate · How-to · Linux, macOS Script Ansible 15 Oct 2026
Repos in the wild
- Ansible: agentless IT automation ansible/ansible The source repository of ansible-core, the engine behind Ansible playbooks, ad-hoc commands and the ansible-galaxy and ansible-vault tools. GitHub
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- Commitpedia +20,277 chars