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
sudoprivileges. - 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:
| Directory | Purpose |
|---|---|
defaults/main.yml | Default variables, lowest precedence. Everything a user may change goes here. |
vars/main.yml | Internal variables with high precedence. Not meant to be overridden. |
tasks/main.yml | The tasks the role runs. |
handlers/main.yml | Handlers, 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.yml | Galaxy 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.
