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 sudo user.
  • Docker Engine installed from Docker's official repository, with your user added to the docker group so docker ps works without sudo.
  • 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.

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.