Skip to content
Commitpedia
Log in Join & edit

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?

Edit history of Automate three servers with Ansible: roles, templates, Vault and safe dry runs: 1 recorded edit
Edit history · 1 edit
Type
Article
Edits
1
Editors
1
First issued
15 Oct 2026
Last edit
15 Oct 2026
Reading
14 min

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-core installed in a Python virtual environment.
  • An inventory with the groups web (web1, web2), db (db1) and a parent group lab.
  • 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) and db (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 reports changed=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 Gateway status line: that still proves nginx is running with your site. Without the Host header, the distribution’s default site would answer instead.
  • PostgreSQL is active and the postgres user’s crontab contains the nightly pg_dumpall entry.
  • /etc/myapp/app.env is mode 600 and owned by root.
  • Each server’s message of the day shows its own name and groups.
  • A second ansible-playbook site.yml reports changed=0 everywhere.

If something goes wrong

  • Host shows UNREACHABLE. Ansible could not log in over SSH. Test with ssh -i ~/.ssh/ansible_lab admin@IP, check remote_user and private_key_file in ansible.cfg, and run with -vvv to 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_file in ansible.cfg points to an existing file, or pass --ask-vault-pass. Note that --syntax-check does not load group variables, so it can pass while a real run fails here.
  • Missing sudo password. Tasks with become: true need root. Add -K or 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-changed from the project root to see which file is used.
  • A variable has an unexpected value. Run ansible-inventory --host web1 to see the merged host and group variables, and remember that group_vars override role defaults.
  • 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_mode as 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 .gitignore for .venv/, and run ansible-playbook site.yml --syntax-check in CI on every merge request.
  • Add ansible-lint to catch non-idempotent tasks and deprecated syntax early.
  • Split the inventory into inventory/staging and inventory/production and 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

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.

  1. Commitpedia +20,277 chars

Report a problem

Something wrong, unsafe or copied? Tell the editors.
What is wrong?