Molecule is the standard tool for testing Ansible roles. For each test run it creates disposable instances, applies your role, applies it a second time to prove it is idempotent, runs verification tasks and destroys the instances. In this tutorial you will build a small Nginx role, test it with Molecule's Docker driver against Ubuntu 24.04 and Rocky Linux 9 containers, lint it with ansible-lint, and run the same tests in GitHub Actions. The workstation used is Ubuntu 24.04.
Prerequisites
To follow this tutorial you need:
- A machine running Ubuntu 24.04 LTS, such as a CubePath VPS or your own workstation, with a non-root
sudouser. - Docker Engine installed from Docker's official repository, with your user added to the
dockergroup sodocker psworks withoutsudo. - At least 2 GB of RAM and a few GB of disk for the container images.
- Basic knowledge of Ansible roles (
tasks,handlers,templates,defaults).
Check that Docker works for your user:
docker run --rm hello-world
Step 1 - Installing Molecule in a virtual environment
Ubuntu 24.04 does not allow pip install into the system Python, and a virtual environment keeps Molecule's versions independent of the OS anyway. Install the venv module and create one:
sudo apt update
sudo apt install python3-venv
python3 -m venv ~/.venvs/molecule
source ~/.venvs/molecule/bin/activate
Install Ansible, Molecule, the Docker driver (shipped in molecule-plugins) and ansible-lint:
pip install ansible-core molecule "molecule-plugins[docker]" ansible-lint
The Docker driver creates containers with modules from the community.docker collection, which ansible-core does not include:
ansible-galaxy collection install community.docker
Check the installation:
molecule --version
molecule drivers
The list of drivers should include docker. Every new shell needs source ~/.venvs/molecule/bin/activate before running Molecule.
Step 2 - Creating the role to test
Create the directory structure for a role called nginx_web:
mkdir -p ~/roles/nginx_web/{defaults,handlers,meta,tasks,templates}
cd ~/roles/nginx_web
Define the role's variables with sensible defaults:
nano defaults/main.yml
---
nginx_web_port: 8080
nginx_web_root: /var/www/nginx_web
nginx_web_message: "Deployed by Ansible"
Write the tasks. They work on both Debian and Red Hat family systems because they use the generic package and service modules and Nginx's conf.d directory, which both families include:
nano tasks/main.yml
---
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_facts['os_family'] == 'Debian'
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Create web root
ansible.builtin.file:
path: "{{ nginx_web_root }}"
state: directory
mode: "0755"
- name: Deploy index page
ansible.builtin.copy:
content: "{{ nginx_web_message }}\n"
dest: "{{ nginx_web_root }}/index.html"
mode: "0644"
- name: Deploy site configuration
ansible.builtin.template:
src: nginx_web.conf.j2
dest: /etc/nginx/conf.d/nginx_web.conf
mode: "0644"
notify: Reload nginx
- name: Enable and start nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
Add the handler:
nano handlers/main.yml
---
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
Add the site template:
nano templates/nginx_web.conf.j2
server {
listen {{ nginx_web_port }};
server_name _;
root {{ nginx_web_root }};
index index.html;
}
Finally, add role metadata. Molecule and ansible-lint use namespace and role_name to build the role's fully qualified name:
nano meta/main.yml
---
galaxy_info:
namespace: your_namespace
role_name: nginx_web
author: your_name
description: Install nginx and serve a static site
license: MIT
min_ansible_version: "2.16"
platforms:
- name: Ubuntu
versions:
- noble
- name: EL
versions:
- "9"
dependencies: []
Replace your_namespace with a lowercase name such as your company or GitHub user (letters, digits and underscores only).
Step 3 - Writing the Molecule scenario
A scenario is a directory under molecule/ describing which instances to create and how to test them. molecule init scenario can generate one, but writing the three files by hand makes it clear what each one does:
mkdir -p molecule/default
nano molecule/default/molecule.yml
---
driver:
name: docker
platforms:
- name: ubuntu2404
image: geerlingguy/docker-ubuntu2404-ansible:latest
command: ""
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: true
- name: rockylinux9
image: geerlingguy/docker-rockylinux9-ansible:latest
command: ""
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: true
provisioner:
name: ansible
verifier:
name: ansible
The images come with Python and systemd preinstalled, which the role needs to manage the nginx service. command: "" keeps the image's default command (systemd) instead of Molecule's default sleep loop, and the cgroup mount, cgroupns_mode: host and privileged: true let systemd run inside the container.
Warningprivileged containers have broad access to the host. Run Molecule on a development machine or a CI runner, never on a production server.
The converge playbook applies the role. MOLECULE_PROJECT_DIRECTORY is the role's directory, so the playbook keeps working if you copy it to other roles:
nano molecule/default/converge.yml
---
- name: Converge
hosts: all
become: true
tasks:
- name: Include the role under test
ansible.builtin.include_role:
name: "{{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}"
The verify playbook checks the result from inside each instance:
nano molecule/default/verify.yml
---
- name: Verify
hosts: all
gather_facts: false
tasks:
- name: Collect service facts
ansible.builtin.service_facts:
- name: Assert nginx is running
ansible.builtin.assert:
that:
- ansible_facts['services']['nginx.service']['state'] == 'running'
fail_msg: nginx is not running
- name: Fetch the site
ansible.builtin.uri:
url: http://localhost:8080/
return_content: true
register: site
- name: Assert the page content
ansible.builtin.assert:
that:
- "'Deployed by Ansible' in site.content"
Step 4 - Running the tests step by step
During development, run the stages separately so you can inspect the instances between them. Create the containers:
molecule create
docker ps --format '{{.Names}}\t{{.Image}}'
rockylinux9 geerlingguy/docker-rockylinux9-ansible:latest
ubuntu2404 geerlingguy/docker-ubuntu2404-ansible:latest
Apply the role:
molecule converge
The run ends with a normal Ansible PLAY RECAP for ubuntu2404 and rockylinux9. On the first run several tasks report changed, and both hosts must show failed=0.
Run the verification playbook:
molecule verify
All assertions should pass on both hosts. If something fails, open a shell inside an instance to investigate:
molecule login --host rockylinux9
Inside the container you can run systemctl status nginx or curl -s localhost:8080. Type exit to leave. After fixing the role, run molecule converge again: the containers are reused, so iterations take seconds.
Step 5 - Running the full test sequence
molecule test runs the complete sequence from scratch: it destroys leftovers, checks the playbook syntax, creates the instances, converges, runs converge a second time for the idempotence check, verifies and destroys everything:
molecule test
The idempotence step fails if any task reports changed on the second run. That catches common bugs such as command or shell tasks without changed_when, or templates that render differently each time. Towards the end of the output, the idempotence converge shows changed=0 for every host, and the command exits with status 0:
echo $?
0
To keep the instances after a failure for debugging, use molecule test --destroy never, then clean up with molecule destroy.
Step 6 - Linting the role
Molecule no longer runs linters itself. Run ansible-lint from the role directory; it also runs yamllint rules and checks things like fully qualified module names, file permissions and task names:
ansible-lint
A clean role ends with a Passed: 0 failure(s), 0 warning(s) line and exit status 0.
If you need to disable a rule, do it explicitly in a .ansible-lint file at the role root, for example:
---
skip_list:
- yaml[line-length]
Step 7 - Running Molecule in GitHub Actions
GitHub's Ubuntu runners include Docker, so the same commands work in CI. Put the role in its own repository (with the role files at the root), and pin the Python dependencies in a requirements.txt:
ansible-core
molecule
molecule-plugins[docker]
ansible-lint
Add a workflow file:
mkdir -p .github/workflows
nano .github/workflows/molecule.yml
---
name: Molecule
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-24.04
steps:
- name: Check out the role
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
ansible-galaxy collection install community.docker
- name: Lint
run: ansible-lint
- name: Run Molecule
run: molecule test
env:
PY_COLORS: "1"
ANSIBLE_FORCE_COLOR: "1"
Push to a branch and open a pull request: the job lints the role and runs the full Molecule sequence on both platforms. Pin exact versions in requirements.txt (for example with pip freeze from your working venv) once the pipeline is green, so an upstream release does not break CI unexpectedly.
Troubleshooting
Failed to find driver docker. The Docker driver is not installed in the active environment. Activate the venv and run pip install "molecule-plugins[docker]".
couldn't resolve module/action 'community.docker.docker_container'. Install the collection with ansible-galaxy collection install community.docker.
The nginx service fails with System has not been booted with systemd. The container is not running systemd. Check command: "", the cgroup volume, cgroupns_mode: host and privileged: true in molecule.yml.
Computed fully qualified role name ... does not follow current galaxy requirements. meta/main.yml is missing namespace or role_name, or they contain characters other than lowercase letters, digits and underscores.
Idempotence fails on a command or shell task. Add changed_when: false for read-only commands, or creates:/removes: so Ansible knows when the command has nothing to do.
Conclusion
You now have an Ansible role that is tested on Ubuntu 24.04 and Rocky Linux 9 containers with Molecule, checked for idempotence, verified with assertions, linted and run automatically in GitHub Actions. From here, add a second scenario (for example molecule/custom_port/) that sets different variables in its converge playbook and run it with molecule test -s custom_port, add Debian 12 to the platform list, and require the workflow to pass before merging changes to the role.
