(。◕‿‿◕。) Sylvain's blog

Ansible overview

Aug 14, 2025

What is Ansible

Ansible is a DevOps tool used to deploy and manage server configurations.

When using Ansible, you should NOT modify anything on the servers manually, as it will create differences between what the machine’s state is, and what it is supposed to be. Ansible will reapply its configurations on tracked files, losing changes, and untracked files will be lost when deploying to a new machine. Manual operations are shorter at the moment, but will take longer in the run.

On this overview, I am using code samples from my own infrastructure.

General Organization

Repository Structure

./
├── ansible.cfg
├── common_handlers.yml
├── group_vars/
├── host_vars/
├── inventory/
├── inventory_plugins/
├── library/
├── playbooks/
└── roles/

Root explanations

In the root directory of the repo, we have a few files and directories. We try to keep the least amount of files in here and put them inside specialized directories. Everything in Ansible is made with yaml files, which is pretty neat.

Playbooks directory

We’ll start with the playbooks directory, where our playbooks are located. Playbooks are used to define a list of roles that are applied to a specific set of hosts. Playbooks can have any name you’d like, but try to make it descriptive of what the playbook does, rather than just “playbook.yml”. They are placed directly in the playbooks directory.

Here is an example of a playbook’s block:

- name: Install custom PKI  # Name of the task to run. It should be clear what it is doing.
  hosts: all_local  # The hosts on which the tasks is set to run.
  become: true  # Change the user on the remote machine.
  handlers:  # Add custom handlers to this task.
    # I have some common handlers, which are used than more than one role, reloading systemd for example.
    - name: CommonHandlers
      ansible.builtin.import_tasks: common_handlers.yml
  roles:  # The roles to run on this task. They are directly pulled from the roles directory.
    - pki
    - chrony
  tags:  # Tags to use to identify this block.
    - pki

Handlers are tasks that are run after a block of a playbook is run. For example, it can be used to reload services after configuring them, without reloading them after every change, and handlers are only triggered when a task is changed.

Roles directory

Everything defined in a role can’t be accessed outside of this role.

Tasks

Roles are more complex to create, and they are the core of Ansible. For each role you should have a tasks directory, which will contain the listing of all the tasks to run for this role. You should try to keep this list short, and make clearly defined roles that make the least amount of things to correctly do what it is supposed to do. For example, separating the Docker installation and the Docker containers creation. Every task uses an Ansible module (that can be created by you or the community), and pass arguments to it. For the name of the module, the best practice is to use the full name of the module, including the source (like ansible.builtin.apt).

An example of tasks:

- name: Install dependencies  # Name of the task, choose something cleaner and concise.
  ansible.builtin.apt:  # The module to use for this task, anisble's builtin module apt.
    # Arguments passed to the module.
    name:  # List of names of packages.
      - apt-transport-https
      - ca-certificates
      - curl
      - gnupg-agent
      - software-properties-common
    state: present  # Whether to add it or remove it. Adding it in this case.
    update_cache: true  # Update the cache before installing.

- name: Add GPG key  # Name of the task.
  ansible.builtin.apt_key:  # This task use the Ansible's builtin module apt_key module.
    data: "{{ lookup('hashi_vault', 'secret/data/Infra/gpgkeys:docker') }}"
    # Here we use jinja2 templating to use variables. In this case, we look up
    # a variable located in my Hashicorp Vault, rather than hard coded it here.
    state: present  # Whether to add it or remove it. Adding it in this case.

- name: Add repository to apt  # Name of the task.
  ansible.builtin.apt_repository:  # Using ansible builtin's module apt_repository.
    repo: >  # Yaml syntax for multiline. And the name of the repo to add.
      # We use variables again, this time from Ansible. We'll see how to add them later on.
      deb [arch=]
      https://download.docker.com/linux/
       
    state: present  # Whether to add it or remove it. Adding it in this case.

- name: Install docker  # Name of the task.
  ansible.builtin.apt:  # Using the Ansible's built-in module apt again.
    name:  # List of names of packages.
      - docker-ce
      - docker-ce-cli
      - containerd.io
      - python3-docker
    state: latest  # They need to be present, and up to date.
    update_cache: true  # Update the cache before installing.

- name: Deploy daemon.json  # Name of the task.
  ansible.builtin.copy:  # Using Ansible's built-in module copy.
    src: daemon.json  # Source file to copy. We'll see later on where we can add them.
    dest: /etc/docker/daemon.json  # Destination of the file on the remote machine.
    owner: root  # Owner of the file, on the destination machine.
    group: root  # Group of the file, on the destination machine.
    mode: "0644"  # Mode of the file, on the destination machine.
  notify:  # Used to make ansible run a handler after the current playbook's block.
    - Restart Docker  # Notify (run) the "Restart Docker" task.

Tasks defined in handlers follow the same syntax. When a task is run by Ansible, it checks whether the destination has been modified. When printing the status of the task Ansible will either say:

When creating your roles, you should make sure that when running it two times back to back, you don’t have any changes applied on the second run.

A lot of different modules and configurations are available. You can check Ansible’s documentation, which is really good, docs.ansible.com.

Templates / files

Here are the templates / files you want to push to the remote hosts. Files can be binary files, and are copied as is to the host, while templates are processed with jinja2 before being copied to the host. As Ansible uses jinja2 which is pretty well documented, you shouldn’t have any issue doing what you want to do, there are plenty of good documentations of it online.

Vars / Default

Inside the directory of a role, you can have a vars or / and a default directories. They are used to define variables. The difference between the two is the priorities at which they are used, default being the lowest priority of the two (here is the variable precedence of ansible: docs.ansible.com). The default directory is mainly used to define variables that should be overridden when using the roles, while vars contain local variables used by the roles. Variables file structure is a dict in yaml. Jinja templating can also be used in variables.

An example of a var file:

---
amdgpu_version: "6.2.60202-1"  # Define a variable named "amdgpu_version"
# Define a variable named "amdgpu_deb_path", using the first variable in the value.
amdgpu_deb_path: "/home/caillou/amdgpu-install__all.deb"

Handlers

This is where we define handlers that won’t be reused by other roles, like reloading a really specific service for example. Handlers are defined as tasks, as they are run between them.

Directory Structure

Here is an example of a structure of a few roles:

.
├── ...
├── roles/
│   ├── amdgpu-drivers/
│   │   ├── tasks/
│   │   │   └── main.yml
│   │   └── vars/
│   │       └── main.yml
│   ├── bind9/
│   │   └── tasks/
│   │       └── main.yml
│   ├── ...
│   ├── kubernetes-gitlab-runner/
│   │   ├── files/
│   │   │   └── gitlab-runner-0.77.1.tgz
│   │   ├── tasks/
│   │   │   └── main.yml
│   │   ├── templates/
│   │   │   ├── buildah-config.yml
│   │   │   ├── roles.yml
│   │   │   └── values.yml
│   │   └── vars/
│   │       └── main.yml
│   └── ...
└── ...

Inventory / Inventory_plugins

Inventory files are used to define the hosts that Ansible will execute on. Inventories can be static or dynamic. Static inventory defines an IP, and variables associated to with IP, while dynamic inventory uses another source to get the lists of machines (GCP, or AWS for example). Dynamic inventory can be python scripts, or yaml files using Ansible’s module. Like most of Ansible, you can also create a module to list hosts from a source that isn’t supported (I created one to list my VPS on OVH, for example).

Inventory files are placed directly in the inventory directory, and can be named whatever you want. You should however keep a clear and concise name.

Inventory plugins should be placed in the inventory_plugins directory.

Example of a dynamic inventory using GCP Compute instances as a source:

plugin: google.cloud.gcp_compute  # Inventory plugin to use.
zones:  # List of GCP zones to look on.
  - europe-west9-c
  - europe-west9-a
  - europe-west9-b
projects:  # Name of the GCP project.
  - mindful-zebra-471823
service_account_file: gcp_auth.json  # Path of the secrets to connect to GCP.
auth_kind: serviceaccount  # Way to authenticate to GCP.

hostnames:  # What to use for the hostnames of the servers.
  - labels.vm_name

compose:  # Add variables to the servers.
  primary_ip: networkInterfaces[0].accessConfigs[0].natIP

groups:  # Add servers to groups, based on conditions.
  heartbeat: "'is_ansible' in labels"
  gcp_compute: "true"
  access_elk: "'access_elk' in labels"

Example of a static inventory:

---
nginx:  # Group to add the server to.
  hosts:  # List os hosts.
    elk-node-1:  # Host name of the server.
      ansible_host: 192.168.23.150  # Variables to add to the host.

heartbeat:  # Group to add the server to.
  hosts:  # List of hosts
    elk-node-1:  # Host name of the server.
      ansible_host: 192.168.23.150  # Variables to add to the host.

Group_vars / Host_vars

These files define variables used in multiple roles. group_vars are accessible by a group of hosts, while host_vars defines variables for only one host. An example usage of group vars vs host vars would be to define the path of a password on vault, specific to hosts in the host vars, while grouping hosts by local network and remote, and setting local network vars in a group_vars, and these for remote in another group_vars.

To add variables in a group, you need to create a directory with the same name inside group_vars, then create yaml files inside the group. You can do the same for host_vars, or just create yaml files in the host_vars directory with the same name as the host, ending with yml (like pki.yml).

It is cleaner to define host’s variables in the host_vars directory, rather than directly in the inventory. Doing it this way makes it clearer where to look for when looking for a variable, and doesn’t make 500k lines of inventory files.

Structure of group_vars and host_vars:

./
├── ...
├── group_vars/
│   ├── all/
│   │   ├── certbot.yml
│   │   ├── services_ports.yml
│   │   └── ...
│   ├── heartbeat/
│   │   └── main.yml
│   └── kube_nodes/
│       └── main.yml
├── host_vars/
│   ├── alerting.yml
│   ├── dns-1.yml
│   ├── kube-haproxy-slave.yml
│   └── ...
└── ...

Library directory

You can place custom modules here. They are coded in Python, and can then be accessed from every task.

Loose files

The ansible.cfg file contains configuration directives for Ansible, like the path of the library for example. It allows us to use playbooks inside the playbooks directory, by specifying where Ansible should try to locate some key components.

I use common_handlers.yml to define handlers that are used by more than one role, this way I don’t have to duplicate code.

Ansible Galaxy

As the universe is ever expanding, Ansible can be expanded using Ansible Galaxy. It is the central repository to share and download Ansible modules made by the community and for the community. As Ansible is widely used, there is a really big community behind it, and that is reflected in Ansible Galaxy. You can easily access it using the ansible-galaxy CLI.

It can be used to do some common tasks made by the community, instead of writing it yourself.

Ansible Galaxy distributes two types of resources: