Skip to content
DBDeependra Bhatta~/notes
Infrastructure as Code#ssh · #ansible · #configuration-management · #security

Ansible Templates, Handlers, Roles and Vault

Push chrony NTP config with templates, edit sshd_config with lineinfile, restart services only on change with handlers, then organize it all into a role and encrypt secrets with Vault.

· updated · 12 min read
ON THIS PAGE

A single playbook is enough for a lab, but automation shared across a team needs a reusable structure, controlled service restarts and protected secrets. Continuing from Ansible Playbooks, this guide edits configuration files on Ubuntu and CentOS VMs with the template and lineinfile modules, restarts services only when their config changes, moves the work into a role, and encrypts sensitive files with Ansible Vault.

The file module

ansible.builtin.file manages files and directories themselves, not their content: it creates files, directories and symbolic links, deletes them, and sets owner, group and permissions.

YMLplaybook.yaml
- name: Change file ownership, group and permissions
  ansible.builtin.file:
    path: /etc/foo.conf
    owner: foo
    group: foo
    mode: "0644"

Quote mode. Without quotes, YAML reads 0644 as a number, which can give the wrong permissions.

Lab setup: shared folder and VS Code

Editing YAML in vim inside the VM is slow, so this lab shares a folder between the laptop and the Ansible control VM and edits files in VS Code. Add the synced folder to the Vagrantfile (see the Vagrant guide):

RUBVagrantfile
config.vm.synced_folder "./shared_folder", "/home/vagrant/shared_folder"

In VS Code, install the Red Hat YAML extension and open its "Yaml: Schemas" setting with "Edit in settings.json":

VS Code Red Hat YAML extension with the Yaml: Schemas setting and its Edit in settings.json link

settings.json with yaml.schemas entries mapping ansible to *.yaml and kubernetes to .yaml

The shared folder introduces two problems:

  • ansible.cfg was ignored. The VirtualBox shared folder is mounted world-writable (anyone can write to it). Ansible refuses to load ansible.cfg from a world-writable current directory, because another user could plant a malicious config there. Inside the shared folder, pass the inventory on every run: ansible-playbook -i inventory playbook.yaml. Setting ANSIBLE_CONFIG to the file's path also works.
  • SSH keys failed with "bad permissions". chmod 600 has no effect on files in the shared folder, so SSH rejects a private key stored there. Keep the key in a regular directory on the VM instead.

Templates: push the same NTP config to every server

NTP (Network Time Protocol) keeps computer clocks in sync with time servers. Ubuntu and CentOS both use chrony as the NTP client. Its config is at /etc/chrony/chrony.conf on Ubuntu and /etc/chrony.conf on CentOS. The default Ubuntu config uses Ubuntu's pool servers:

Default Ubuntu chrony.conf pool lines for ntp.ubuntu.com and 0 to 2.ubuntu.pool.ntp.org with iburst and maxsources

This lab targets time servers close to Nepal. The NTP Pool page for Nepal (np.pool.ntp.org) says the zone has too few servers and recommends the Asia zone, asia.pool.ntp.org, instead.

Changing a few lines on one machine is manageable. Repeating that change on many machines, each with a slightly different file, leads to drift and mistakes. Instead, edit the file once on the control node and let Ansible push it. The template module copies a file to the hosts and first renders it with Jinja2, the templating engine Ansible uses for {{ variables }}, conditions and loops inside files.

Create one template per distribution, because their default files differ:

VS Code explorer showing a template folder with centos.conf.j2 and ubuntu.conf.j2

±ubuntu.conf.j2+4−4
-pool ntp.ubuntu.com        iburst maxsources 4
-pool 0.ubuntu.pool.ntp.org iburst maxsources 1
-pool 1.ubuntu.pool.ntp.org iburst maxsources 1
-pool 2.ubuntu.pool.ntp.org iburst maxsources 2
+pool 0.asia.pool.ntp.org iburst maxsources 4
+pool 1.asia.pool.ntp.org iburst maxsources 1
+pool 2.asia.pool.ntp.org iburst maxsources 1
+pool 3.asia.pool.ntp.org iburst maxsources 2
TXTcentos.conf.j2 (new pool lines)
templatecentos.conf.j2 (new pool lines)
pool 0.asia.pool.ntp.org iburst
pool 1.asia.pool.ntp.org iburst
pool 2.asia.pool.ntp.org iburst
pool 3.asia.pool.ntp.org iburst

The .j2 extension is a convention that tells people (and editors) the file is a Jinja2 template. Ansible does not require it. These templates have no variables yet, so they behave like copy. Templates become valuable when you replace a value with a variable, for example pool {{ ntp_pool }} iburst, and set ntp_pool per group.

The template tasks pick the right source and destination for each distribution:

YMLplaybook.yaml
    - name: Update chrony config on Ubuntu
      ansible.builtin.template:
        src: ./template/ubuntu.conf.j2
        dest: /etc/chrony/chrony.conf
      when: ansible_distribution == "Ubuntu"
 
    - name: Update chrony config on CentOS
      ansible.builtin.template:
        src: ./template/centos.conf.j2
        dest: /etc/chrony.conf
      when: ansible_distribution == "CentOS"

My first version ended with a task that restarted chronyd on every run, even when nothing had changed. The handlers section below fixes that.

Edit one line with lineinfile: an SSH login banner

The ansible.builtin.lineinfile module makes sure one line in a file is present (or absent). With regexp it finds an existing line and replaces it. The example below uses it to display a warning banner before SSH login. By default sshd_config has the banner commented out:

grep on /etc/ssh/sshd_config shows the line #Banner none

The playbook writes the banner text, points sshd_config at it, and restarts SSH. The service is called ssh on Ubuntu and sshd on CentOS, so there are two restart tasks.

YMLplaybook.yaml
---
- name: Add an SSH login banner
  hosts: all
  become: true
  tasks:
    - name: Write the banner text
      ansible.builtin.copy:
        dest: /etc/banner.txt
        content: |
          ***************************************
          Welcome to Ansible-Managed Server!
          If you are not an authorized user,
          please logout immediately.
          ***************************************
 
    - name: Point sshd_config at the banner
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^#Banner none"
        line: "Banner /etc/banner.txt"
        state: present
 
    - name: Restart ssh on Ubuntu
      ansible.builtin.service:
        name: ssh
        state: restarted
      when: ansible_distribution == "Ubuntu"
 
    - name: Restart sshd on CentOS
      ansible.builtin.service:
        name: sshd
        state: restarted
      when: ansible_distribution == "CentOS"

After the run, the line is replaced on both distributions:

sshd_config now contains Banner /etc/banner.txt under the no default banner path comment

And a new SSH login shows the banner before the password prompt:

ssh to 192.168.56.211 prints the Welcome to Ansible-Managed Server banner before asking for the password

Handlers: restart only when something changed

A handler is a task that runs only when another task notifies it, and only if that task reported changed. This is the usual way to restart a service after its config file changes. Handlers run once at the end of the play, even if several tasks notify them.

YMLplaybook.yaml
---
- name: Provision servers
  hosts: all
  become: true
  tasks:
    - name: Install packages on Ubuntu
      ansible.builtin.apt:
        name: "{{ item }}"
        state: present
        update_cache: true
      when: ansible_distribution == "Ubuntu"
      loop:
        - chrony
        - wget
        - git
        - zip
        - unzip
 
    - name: Install packages on CentOS
      ansible.builtin.dnf:
        name: "{{ item }}"
        state: present
      when: ansible_distribution == "CentOS"
      loop:
        - chrony
        - wget
        - git
        - zip
        - unzip
 
    - name: Start and enable chronyd
      ansible.builtin.service:
        name: chronyd
        state: started
        enabled: true
 
    - name: Update chrony config on Ubuntu
      ansible.builtin.template:
        src: ./template/ubuntu.conf.j2
        dest: /etc/chrony/chrony.conf
      notify: Restart chronyd
      when: ansible_distribution == "Ubuntu"
 
    - name: Update chrony config on CentOS
      ansible.builtin.template:
        src: ./template/centos.conf.j2
        dest: /etc/chrony.conf
      notify: Restart chronyd
      when: ansible_distribution == "CentOS"
 
  handlers:
    - name: Restart chronyd
      ansible.builtin.service:
        name: chronyd
        state: restarted

On Ubuntu the unit is chrony.service, but it also answers to the name chronyd, so one handler covers both distributions.

After a small edit to a template, the handler runs:

RUNNING HANDLER Restart chronyd service reports changed on web_VM, db_VM and centosvm1

On a second run with no changes, the template tasks report ok and the handler does not run:

Second run: template task ok on centosvm1, no handler, and PLAY RECAP with changed=0 on all three hosts

Roles: organize and reuse

As playbooks grow, one file becomes hard to read. A role is a standard folder layout that Ansible understands. It loads tasks, handlers, templates, files and variables from fixed places, so a role can be reused in many playbooks and shared with others.

TXTPlain text
roles/
  common/            # one role
    tasks/main.yml     # tasks; can import smaller task files
    handlers/main.yml  # handlers
    templates/         # files for the template module
    files/             # files for the copy and script modules
    vars/main.yml      # role variables (high priority)
    defaults/main.yml  # default variables (lowest priority, easy to override)
    meta/main.yml      # role metadata and dependencies

Inside a role, modules find their files automatically: template: src=ubuntu.conf.j2 looks in the role's templates/ folder, and copy: src=index.html looks in files/.

Ansible Galaxy

Ansible Galaxy is the public hub for roles and collections written by the community. You can download one instead of writing your own. The ansible-galaxy command manages both:

ansible-galaxy collection --help and ansible-galaxy role --help listing actions such as init, install, list, remove and delete

Create a custom role

terminal
$ ansible-galaxy role init demo_role

tree of the new demo_role with defaults, files, handlers, meta, tasks, templates, tests and vars folders

init creates only the skeleton. Move the work from the earlier playbooks into it:

  • Split the tasks into separate files (banner.yaml, chrony.yaml, setupdb.yaml, useradd.yaml) in tasks/.
  • Make tasks/main.yml call those files.
  • Move the chrony templates to templates/, index.html to files/, variables to vars/main.yml (or defaults/main.yml), and the restart handler to handlers/main.yml.

demo_role tree with banner, chrony, setupdb and useradd task files, two chrony templates and index.html

tasks/main.yml only pulls in the other task files, for example:

YMLmain.yml
demo_roletasksmain.yml
---
- name: Configure chrony
  ansible.builtin.import_tasks: chrony.yaml
 
- name: Add SSH banner
  ansible.builtin.import_tasks: banner.yaml
 
- name: Set up the database
  ansible.builtin.import_tasks: setupdb.yaml
 
- name: Add users
  ansible.builtin.import_tasks: useradd.yaml

The playbook itself shrinks to the hosts and the role name:

YMLplaybook.yaml
---
- name: Provision servers with demo_role
  hosts: all
  become: true
  roles:
    - demo_role

The practice files for this series are in the Ansible_Practices repository.

ansible-galaxy role commands

CommandWhat it does
ansible-galaxy role init <name>creates a new role skeleton
ansible-galaxy role install <name>downloads a role from Galaxy
ansible-galaxy role listlists installed roles
ansible-galaxy role info <name>shows details about a role
ansible-galaxy role remove <name>deletes an installed role from your machine
ansible-galaxy role delete <name>removes a role you published from the Galaxy server (not from your machine)

Ansible Vault: encrypt secrets

Passwords, keys and tokens should not sit in plain text in a Git repository. Ansible Vault encrypts whole files (or single strings) with a password, using AES-256. Ansible decrypts them in memory at run time when you give it the vault password. You can encrypt inventories, group_vars and host_vars files, role variables, and even task files.

In this lab, the inventory holds the VM passwords, so encrypt it:

terminal
$ ansible-vault encrypt inventory
New Vault password:
Confirm New Vault password:
Encryption successful

ansible-vault encrypt inventory succeeds and cat inventory shows the $ANSIBLE_VAULT;1.1;AES256 header and ciphertext

To run a playbook that uses encrypted files, either type the password when asked or read it from a file:

terminal
$ ansible-playbook playbook.yaml --ask-vault-pass
$ ansible-playbook --vault-password-file=/home/vagrant/mypass playbook.yaml

Running ansible-playbook with --vault-password-file=/home/vagrant/mypass starts the play and gathers facts

CommandWhat it does
ansible-vault create <file>creates a new encrypted file and opens it in an editor
ansible-vault encrypt <file>encrypts an existing file
ansible-vault decrypt <file>decrypts a file back to plain text
ansible-vault view <file>shows the content without decrypting the file on disk
ansible-vault edit <file>opens the encrypted file in an editor
ansible-vault rekey <file>changes the vault password
ansible-vault encrypt_stringencrypts one value to paste into a YAML file

The official Ansible Vault guide covers multiple vault IDs and other options.

Beyond the command line: AWX and Automation Platform

Ansible Tower was Red Hat's web UI and API for running Ansible with access control, scheduling and logs. It is now part of the paid Red Hat Ansible Automation Platform. Its free, open-source upstream project is AWX. AWX releases are currently paused while the project is being refactored.

Common mistakes

  • ansible.cfg ignored in a Vagrant shared folder: the folder is world-writable. Pass -i inventory or set ANSIBLE_CONFIG.
  • SSH key "bad permissions" in a shared folder: move the key to a normal directory and chmod 600 it.
  • Service restarted on every run: use notify with a handler instead of a plain restart task.
  • ansible-galaxy role delete did not remove the local role: delete acts on the Galaxy server; use remove for local roles.
  • Unquoted file mode: write mode: "0644", not mode: 0644.

Key takeaways

  • template renders a Jinja2 file and copies it; lineinfile changes a single line; file manages permissions, ownership and links.
  • Handlers run only when a notifying task reports changed, and only once at the end of the play.
  • Roles give tasks, handlers, templates, files and variables a fixed place, so playbooks stay short and reusable.
  • Ansible Vault encrypts secrets with AES-256; keep the vault password file out of Git.
  • Tower is now part of Red Hat Ansible Automation Platform; AWX is its open-source upstream.

This is the last part of the Ansible series. Start from the beginning with Getting Started with Ansible.