Category: Podman

Molecule + Podman: Why It’s a Pain, but here’s an Example That Finally Works

If you’ve ever tried to run Molecule with Podman instead of Docker, you already know the truth: it never “just works.” Between dynamic inventories, missing Python interpreters, inconsistent container images, and Molecule’s habit of generating YAML that looks like it was written by a committee, you spend more time debugging the framework than testing your Ansible roles.

This post walks through a real, working Molecule + Podman setup that supports multiple OS containers (Ubuntu, Rocky, CentOS Stream), uses Podman natively, and avoids the usual landmines:

  • Python missing inside containers
  • Fact‑gathering failures
  • Molecule’s dynamic inventory breaking
  • Destroy steps leaving orphaned containers
  • Podman connection plugin quirks

If you’ve been fighting Molecule + Podman, this example will save you hours.

The Problem: Molecule + Podman Is Not a Drop‑In Replacement for Docker

Molecule’s Docker driver is mature. Podman’s driver… well… it isn’t.

If you’re testing roles across multiple OSes, these problems multiply and you eventually just give up.

The Working Example: Multi‑OS Molecule Scenario Using Podman

Here’s the structure:

molecule/default/
├── create.yml
├── prepare.yml
├── converge.yml
├── verify.yml
├── destroy.yml
├── molecule.yml
└── requirements.yml

Key idea:

We start our own containers with podman and generate our own dynamic inventory inside create.yml and force Molecule to use it.

This avoids Molecule’s broken Podman inventory plugin entirely.

1. create.yml Start Containers + Build Inventory

We define our Podman containers, start them and force feed the inventory to molecule:

- name: Create
  hosts: localhost
  gather_facts: false
  vars:
    molecule_inventory:
      all:
        hosts: {}
        molecule: {}
    
    podman:
      - name: ubuntu-2404
        image: ubuntu:24.04
        command: /bin/bash
        tty: true
      - name: rocky9
        image: quay.io/rockylinux/rockylinux:9  
        command: /bin/bash
        tty: true
      - name: rocky10
        image: quay.io/rockylinux/rockylinux:10
        command: /bin/bash
        tty: true
      - name: cs9
        image: quay.io/centos/centos:stream9
        command: /bin/bash
        tty: true
  tasks:

    - name: Start Podman container(s)
      containers.podman.podman_container:
        name: "{{ item.name }}"
        image: "{{ item.image }}"
        state: started
        command: "{{ item.command | default(omit) }}"
        tty: "{{ item.tty | default(omit) }}"
      register: result
      loop: "{{ podman }}"
      loop_control:
        label: "{{ item.name }}"

    - name: Add container to molecule_inventory
      vars:
        inventory_partial_yaml: |
          all:
            children:
              molecule:
                hosts:
                  "{{ item.name }}":
                    ansible_connection: containers.podman.podman
      ansible.builtin.set_fact:
        molecule_inventory: >
          {{ molecule_inventory | combine(inventory_partial_yaml | from_yaml, recursive=true) }}
      loop: "{{ podman }}"
      loop_control:
        label: "{{ item.name }}"

    - name: Dump molecule_inventory
      ansible.builtin.copy:
        content: |
          {{ molecule_inventory | to_yaml }}
        dest: "{{ molecule_ephemeral_directory }}/inventory/molecule_inventory.yml"
        mode: "0600"

    - name: Force inventory refresh
      ansible.builtin.meta: refresh_inventory

    - name: Fail if molecule group is missing
      ansible.builtin.assert:
        that: "'molecule' in groups"
        fail_msg: |
          molecule group was not found inside inventory groups: {{ groups }}
      run_once: true # noqa: run-once[task]

# we want to avoid errors like "Failed to create temporary directory"
- name: Validate that inventory was refreshed
  hosts: molecule
  gather_facts: false
  tasks:
    - name: Check uname
      ansible.builtin.raw: uname -a
      register: result
      changed_when: false

    - name: Display uname info
      ansible.builtin.debug:
        msg: "{{ result.stdout }}"

It’s important to note, we added our containers to the inventory with the podman ansible_connection, containers.podman.podman_container.

Out inventory file under the covers looks like this:

all:
  children:
    molecule:
      hosts:
        ubuntu-2404:
          ansible_connection: containers.podman.podman
        rocky9:
          ansible_connection: containers.podman.podman
        rocky10:
          ansible_connection: containers.podman.podman
        cs9:
          ansible_connection: containers.podman.podman

And note that we force Molecule to refresh:

- meta: refresh_inventory

This is the magic step most examples miss.

2. prepare.yml Only Touch Ubuntu Containers

Instead of conditionals, we use a host pattern and fix the missing python3 in the Ubuntu containers.

- hosts: "ubuntu*"
  gather_facts: false
  tasks:
    - name: Refresh apt cache Ubuntu container
      raw: |
        apt-get update
    - name: Install Python inside Ubuntu container
      raw: |
        apt-get install -y python3
    - name: Set correct Python interpreter for Ubuntu
      set_fact:
        ansible_python_interpreter: /usr/bin/python3

Why this works:

  • Molecule names your Ubuntu containers ubuntu-*
  • No need for OS detection
  • No need for fact gathering

This is the cleanest possible prepare step.

3. converge.yml Run Tasks on All Containers

This is the thing we’re testing

- hosts: all
  tasks:
    - shell: "cat /etc/*release*"
      register: result
    - debug: msg="{{ result }}"

Simple, predictable, works across all OSes or well, it does what you want your code to do.

4. verify.yml OS‑Specific Assertions

- hosts: rocky9
  tasks:
    - assert:
        that:
          - "'dnf' in ansible_facts.packages"

- hosts: ubuntu-2404
  tasks:
    - assert:
        that:
          - "'apt' in ansible_facts.packages"

This is how you validate OS‑specific behaviour cleanly, again, you do you.

5. destroy.yml Clean Container Removal

- name: Destroy molecule containers
  hosts: molecule
  gather_facts: false
  tasks:
    - name: Delete Podman container(s)
      containers.podman.podman_container:
        name: "{{ item }}"
        state: absent
      loop: "{{ groups['all'] | difference(['localhost']) }}"
      delegate_to: localhost

- name: Remove dynamic molecule inventory
  hosts: localhost
  gather_facts: false
  tasks:

    - name: Remove dynamic inventory file
      ansible.builtin.file:
        path: "{{ molecule_ephemeral_directory }}/inventory/molecule_inventory.yml"
        state: absent

This avoids:

  • Molecule leaving orphaned containers
  • Podman complaining about missing names
  • localhost being treated as a container

Why This Example Works When Others Don’t

1. We bypass Molecule’s broken Podman inventory plugin

We generate our own containers and inventory and force Molecule to use it.

2. We use host patterns instead of OS detection

Fact gathering doesn’t work until Python is installed.

3. We use Podman’s connection plugin correctly

Every host gets:

ansible_connection: containers.podman.podman

4. We refresh inventory explicitly

Without this, Molecule uses stale inventory.

Conclusion: Molecule + Podman Is Painful …. But Fixable

Podman is great. Molecule is great. Together? They fight.

But with:

  • explicit container creation
  • explicit inventory generation
  • host patterns
  • correct Podman connection plugin
  • correct Ubuntu prepare steps
  • correct destroy logic

You can run a multi‑OS Molecule test matrix under Podman reliably.

This example proves it.

If you’re building cloud automation, CI pipelines, or multi‑distro Ansible roles, this pattern will save you hours of frustration.

phpIPAM via podman-compose

You can use containers inside container orchestration platforms and of course you can do that with phpIPAM as well, but in my case I just wanted the convenience of the container packaging approach and running it on a single Linux host without having to worry about the overheads of K8S style platforms.

I was using a RHEL derivative, Alma Linux 9.0 in this case and also using Podman rather than Docker.

I did want to use the docker-compose approach to configuring and maintaining the application. The compose format makes it really quite simple to deploy and maintain simple container applications that are single system hosted.

Since I wasn’t using Docker, rather Podman, I found that you can use a tool called podman-compose to orchestrate podman to deliver the outcome you’d expect from a docker-compose file.

Firstly, start like this, getting podman and pip3 installed.

 yum install podman python3-pip

Then it’s simple to install podman-compose

pip3 install podman-compose

With a docker-compose.yml file similar to the following (change the default passwords i’ve put in the file) you can get going very quickly.

version: '3'

services:
  phpipam-web:
    image: docker.io/phpipam/phpipam-www:latest
    ports:
      - "80:80"
    environment:
      - TZ=Australia/Melbourne
      - IPAM_DATABASE_HOST=phpipam-mariadb
      - IPAM_DATABASE_USER=root
      - IPAM_DATABASE_PASS=<mysql_root_pass>
    restart: unless-stopped
    volumes:
      - phpipam-logo:/phpipam/css/images/logo
    depends_on:
      - phpipam-mariadb

  phpipam-mariadb:
    image: docker.io/library/mariadb:latest
    environment:
      - MARIADB_ROOT_PASSWORD=<mysql_root_pass>
    restart: unless-stopped
    volumes:
      - phpipam-db-data:/var/lib/mysql

volumes:
  phpipam-db-data:
  phpipam-logo:

Then it’s as simple as

podman-compose up -d

Then you connect to the IP address of your underlying system, and execute the installation dialogue. You should only need to enter the MySQL/MariaDB username / password, everything else should be pre-filled with the correct information.

Navigation