An Ansible role packages the tasks, handlers, templates and default variables for one job, such as "install and configure Nginx", so you can reuse it across playbooks and projects. Ansible Galaxy is the public hub where you find roles and collections written by others and publish your own. In this tutorial you will set up an Ansible project on Ubuntu 24.04, write an Nginx role following current conventions, pin external content with requirements.yml, and test the role with ansible-lint and Molecule.

Prerequisites

To follow this tutorial, you need:

  • A control machine running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user with sudo privileges.
  • Docker Engine installed on the control machine, used by Molecule to create test containers.
  • Optionally, a second Ubuntu 24.04 server reachable over SSH with key authentication, to apply the role for real.
  • Basic familiarity with Ansible playbooks and inventories.

Step 1 - Installing Ansible, ansible-lint and Molecule

Installing the tools in a Python virtual environment keeps their versions independent from the system packages and makes it easy to pin the same versions in CI:

sudo apt update
sudo apt install python3-venv
python3 -m venv ~/.venvs/ansible
source ~/.venvs/ansible/bin/activate

Install Ansible, the linter, Molecule and Molecule's Docker driver:

pip install ansible ansible-lint molecule "molecule-plugins[docker]"

Check the installed versions:

ansible --version | head -n 1
molecule --version

The first line shows ansible [core 2.x], and Molecule lists the docker driver among its available plugins. Run source ~/.venvs/ansible/bin/activate again in every new shell before working on the project.

Step 2 - Creating the project layout

Create a project directory with a local roles folder and an ansible.cfg that tells Ansible where to find and install roles and collections:

mkdir -p ~/infra/roles
cd ~/infra
nano ansible.cfg
[defaults]
inventory = inventory.ini
roles_path = ./roles
collections_path = ./collections

Keeping external roles and collections inside the project, instead of in your home directory, means every teammate and the CI runner use exactly the versions pinned for this project.

Generate the skeleton of a new role with ansible-galaxy:

ansible-galaxy role init roles/nginx
- Role roles/nginx was created successfully

The command creates this structure:

DirectoryPurpose
defaults/main.ymlDefault variables, lowest precedence. Everything a user may change goes here.
vars/main.ymlInternal variables with high precedence. Not meant to be overridden.
tasks/main.ymlThe tasks the role runs.
handlers/main.ymlHandlers, such as reloading a service after a config change.
templates/Jinja2 templates rendered with the template module.
files/Static files copied as they are.
meta/main.ymlGalaxy metadata and role dependencies.
tests/A minimal test playbook. Molecule replaces it in Step 6.

You can delete folders the role does not use; here, files/ and vars/ stay empty.

Step 3 - Writing the role

Start with the defaults. Prefix every variable with the role name, so two roles never collide:

nano roles/nginx/defaults/main.yml
---
nginx_server_name: _
nginx_root: /var/www/html
nginx_index_content: "Deployed with Ansible"

Next, the tasks. Use fully qualified module names (ansible.builtin.apt instead of apt) so it is always clear which collection a module comes from:

nano roles/nginx/tasks/main.yml
---
- name: Install Nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
    cache_valid_time: 3600

- name: Deploy the site configuration
  ansible.builtin.template:
    src: default.conf.j2
    dest: /etc/nginx/sites-available/default
    owner: root
    group: root
    mode: "0644"
  notify: Reload nginx

- name: Deploy the index page
  ansible.builtin.copy:
    content: "{{ nginx_index_content }}\n"
    dest: "{{ nginx_root }}/index.html"
    owner: root
    group: root
    mode: "0644"

- name: Ensure Nginx is running and enabled
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

The handler reloads Nginx only when the template actually changes:

nano roles/nginx/handlers/main.yml
---
- name: Reload nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded

Create the template for the default site:

nano roles/nginx/templates/default.conf.j2
# Managed by Ansible. Local changes will be overwritten.
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name {{ nginx_server_name }};

    root {{ nginx_root }};
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

Finally, fill in the metadata that Galaxy and other users read:

nano roles/nginx/meta/main.yml
---
galaxy_info:
  role_name: nginx
  namespace: your_namespace
  author: your_name
  description: Install Nginx and serve a static default site
  license: MIT
  min_ansible_version: "2.15"
  platforms:
    - name: Ubuntu
      versions:
        - noble
  galaxy_tags:
    - nginx
    - web

dependencies: []

Step 4 - Validating role variables with argument specs

A role can declare the variables it accepts in meta/argument_specs.yml. Ansible checks them before running any task and fails early with a clear message when a value has the wrong type, and ansible-doc can display them as documentation:

nano roles/nginx/meta/argument_specs.yml
---
argument_specs:
  main:
    short_description: Install Nginx and serve a static default site
    options:
      nginx_server_name:
        type: str
        default: _
        description: Value of the server_name directive.
      nginx_root:
        type: path
        default: /var/www/html
        description: Document root of the default site.
      nginx_index_content:
        type: str
        default: Deployed with Ansible
        description: Content of index.html.

Ansible runs this validation as an implicit first task of the role. For options without a sensible default, add required: true so the role stops with a clear error when the caller forgets them. Show the resulting documentation with ansible-doc -t role -r roles nginx.

Step 5 - Using roles and collections from Galaxy

External content belongs in a requirements.yml file with pinned versions, so a new release upstream never changes your infrastructure without a review. Roles are named namespace.role_name; collections, which bundle modules, plugins and roles, are named namespace.collection:

nano requirements.yml
---
roles:
  - name: geerlingguy.docker
    version: 7.4.1

collections:
  - name: community.general
    version: ">=9.0.0,<10.0.0"
  - name: ansible.posix

The versions above are examples: check the current ones on galaxy.ansible.com before pinning. Then install everything into the project folders configured in ansible.cfg:

ansible-galaxy install -r requirements.yml

Verify what was installed:

ansible-galaxy role list
ansible-galaxy collection list

Roles and collections installed this way are downloaded artifacts: add roles/geerlingguy.* and collections/ to .gitignore and commit only requirements.yml. Pick popular, maintained content, read its tasks before running it with become, and prefer a collection published by the vendor of the software when one exists.

Step 6 - Testing the role with ansible-lint and Molecule

Run the linter on the whole project. It checks YAML style, module names, missing mode on files and many other common mistakes:

ansible-lint
Passed: 0 failure(s), 0 warning(s) on 8 files. Last profile that met the validation criteria was 'production'.

Molecule goes further: it creates a disposable container, applies the role, applies it a second time to prove it is idempotent, runs your checks and destroys the container. Create a scenario inside the role:

mkdir -p roles/nginx/molecule/default
nano roles/nginx/molecule/default/molecule.yml

Nginx needs systemd to start as a service, so the test uses an Ubuntu 24.04 image built for this purpose, with the settings its maintainer documents for running systemd:

---
driver:
  name: docker
platforms:
  - name: nginx-noble
    image: geerlingguy/docker-ubuntu2404-ansible:latest
    pre_build_image: true
    command: ""
    privileged: true
    cgroupns_mode: host
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
provisioner:
  name: ansible
verifier:
  name: ansible

The converge playbook applies the role:

nano roles/nginx/molecule/default/converge.yml
---
- name: Converge
  hosts: all
  become: true
  tasks:
    - name: Include the role under test
      ansible.builtin.include_role:
        name: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}"
      vars:
        nginx_index_content: "Hello from Molecule"

MOLECULE_PROJECT_DIRECTORY is the role directory, so the playbook always tests the role it lives in.

The verify playbook checks the result from inside the container:

nano roles/nginx/molecule/default/verify.yml
---
- name: Verify
  hosts: all
  gather_facts: false
  tasks:
    - name: Fetch the default page
      ansible.builtin.uri:
        url: http://localhost/
        return_content: true
      register: page

    - name: Check the page content
      ansible.builtin.assert:
        that:
          - "'Hello from Molecule' in page.content"

Run the full test sequence from the role directory:

cd roles/nginx
molecule test

Molecule prints each stage: create, converge, idempotence, verify and destroy. The verification ends with:

TASK [Check the page content] **************************************************
ok: [nginx-noble] => {
    "changed": false,
    "msg": "All assertions passed"
}

If a task reports changed on the second run, the idempotence stage fails and tells you which task to fix. While developing, molecule converge and molecule verify rerun single stages on the same container, and molecule destroy removes it.

Step 7 - Applying the role to a server

Go back to the project root and create an inventory with your server:

cd ~/infra
nano inventory.ini
[web]
web1 ansible_host=your_server_ip ansible_user=your_user

Create a playbook that uses the role and overrides a default:

nano site.yml
---
- name: Configure web servers
  hosts: web
  become: true
  roles:
    - role: nginx
      vars:
        nginx_server_name: your_domain

Preview the changes, then apply them:

ansible-playbook site.yml --check --diff
ansible-playbook site.yml --ask-become-pass

Confirm that Nginx serves the page:

curl http://your_server_ip/
Deployed with Ansible

Run the playbook a second time: the recap should show changed=0, which confirms the role is idempotent on a real server too.

Step 8 - Sharing the role

Inside your organization, the simplest way to share a role is its own Git repository with version tags. Other projects consume it from requirements.yml:

roles:
  - name: nginx
    src: git+https://github.com/your_org/ansible-role-nginx.git
    version: v1.0.0

To publish on Ansible Galaxy, log in to galaxy.ansible.com with your GitHub account, create an API key under your preferences, and import the tagged GitHub repository. If you maintain several related roles and modules, package them as a collection instead (ansible-galaxy collection init your_namespace.your_collection), which Galaxy versions and installs as a single unit.

Conclusion

You created a role with namespaced defaults, a validated template, a handler and argument specs, pinned Galaxy content in requirements.yml, tested the role for idempotence with Molecule and applied it to a server. Next, run ansible-lint and molecule test in your CI pipeline on every change, move secrets such as passwords into Ansible Vault, and group related roles into a collection when the project grows.