Introduction

Ansible Galaxy is the community hub for sharing Ansible roles and collections. This guide covers creating your own roles and collections from scratch, testing them with Molecule and ansible-lint, and publishing to Galaxy (public) or Private Automation Hub (enterprise). Whether you're packaging internal automation for your team or contributing to the community, this is the complete workflow.

Roles vs Collections

FeatureRoleCollection
ContainsTasks, handlers, templates, varsRoles + modules + plugins + docs
Namespaceusername.role_namenamespace.collection_name
Installansible-galaxy role installansible-galaxy collection install
VersioningGit tagsgalaxy.yml version field
Dependenciesmeta/main.ymlgalaxy.yml + requirements.yml
Publish toGalaxy (role)Galaxy (collection) or Automation Hub

Use collections for anything with custom modules, plugins, or multiple roles. Use standalone roles for simple, single-purpose automation.

Create a Role

Scaffold

ansible-galaxy role init my_nginx
cd my_nginx

Structure

my_nginx/
├── defaults/
│   └── main.yml          # Default variables (lowest priority)
├── files/                 # Static files to copy
├── handlers/
│   └── main.yml          # Handlers (notify triggers)
├── meta/
│   └── main.yml          # Role metadata + dependencies
├── molecule/
│   └── default/           # Molecule test scenario
├── tasks/
│   └── main.yml          # Main task list
├── templates/             # Jinja2 templates
├── tests/
│   ├── inventory
│   └── test.yml
├── vars/
│   └── main.yml          # Role variables (high priority)
└── README.md

defaults/main.yml

---
nginx_port: 80
nginx_server_name: _
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_client_max_body_size: 64m
nginx_sites: []

tasks/main.yml

---
- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present

- name: Deploy nginx.conf
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    mode: '0644'
    validate: "nginx -t -c %s"
  notify: restart nginx

- name: Deploy site configs
  ansible.builtin.template:
    src: site.conf.j2
    dest: "/etc/nginx/sites-available/{{ item.name }}.conf"
    mode: '0644'
  loop: "{{ nginx_sites }}"
  notify: reload nginx

- name: Enable sites
  ansible.builtin.file:
    src: "/etc/nginx/sites-available/{{ item.name }}.conf"
    dest: "/etc/nginx/sites-enabled/{{ item.name }}.conf"
    state: link
  loop: "{{ nginx_sites }}"
  notify: reload nginx

- name: Start and enable nginx
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

handlers/main.yml

---
- name: restart nginx
  ansible.builtin.service:
    name: nginx
    state: restarted

- name: reload nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded

meta/main.yml

---
galaxy_info:
  author: Luca Berton
  description: Install and configure nginx web server
  company: AnsibleByExample
  license: MIT
  min_ansible_version: "2.15"

  platforms:
    - name: Ubuntu
      versions:
        - noble
        - jammy
    - name: EL
      versions:
        - "9"

  galaxy_tags:
    - nginx
    - web
    - webserver

dependencies: []

README.md

# my_nginx

Install and configure nginx.

## Requirements

None.

## Role Variables

| Variable | Default | Description |
|---|---|---|
| `nginx_port` | `80` | Listen port |
| `nginx_server_name` | `_` | Default server name |
| `nginx_worker_processes` | `auto` | Worker processes |
| `nginx_sites` | `[]` | List of site configs |

## Example Playbook

    - hosts: webservers
      roles:
        - role: my_nginx
          nginx_port: 8080
          nginx_sites:
            - name: myapp
              domain: app.example.com
              upstream: 127.0.0.1:3000

## License

MIT

Create a Collection

Scaffold

ansible-galaxy collection init my_namespace.my_collection
cd my_namespace/my_collection

Structure

my_namespace/my_collection/
├── galaxy.yml               # Collection metadata
├── README.md
├── docs/
├── meta/
│   └── runtime.yml          # Plugin routing
├── plugins/
│   ├── modules/             # Custom modules
│   │   └── my_module.py
│   ├── inventory/           # Inventory plugins
│   ├── filter/              # Filter plugins
│   ├── lookup/              # Lookup plugins
│   └── callback/            # Callback plugins
├── roles/
│   └── webserver/           # Bundled roles
│       ├── tasks/
│       └── defaults/
├── playbooks/               # Bundled playbooks
├── tests/
└── changelogs/
    └── changelog.yaml

galaxy.yml

---
namespace: my_namespace
name: my_collection
version: 1.0.0
readme: README.md
authors:
  - Luca Berton <luca@lucaberton.it>
description: My collection of Ansible roles and plugins
license:
  - MIT
license_file: LICENSE
tags:
  - infrastructure
  - automation
  - linux
repository: https://github.com/myorg/my_collection
documentation: https://github.com/myorg/my_collection/blob/main/README.md
homepage: https://ansiblebyexample.com
issues: https://github.com/myorg/my_collection/issues

# Dependencies
dependencies:
  community.general: ">=8.0.0"
  ansible.posix: ">=1.5.0"

# Build ignore
build_ignore:
  - .git
  - .github
  - tests/output
  - "*.tar.gz"

Custom Module

# plugins/modules/my_module.py
#!/usr/bin/python
from __future__ import absolute_import, division, print_function
__metaclass__ = type

DOCUMENTATION = r'''
---
module: my_module
short_description: Example custom module
description:
  - This is an example custom module for the collection.
options:
  name:
    description: The name parameter.
    required: true
    type: str
  state:
    description: Desired state.
    choices: ['present', 'absent']
    default: present
    type: str
author:
  - Luca Berton (@lucab85)
'''

EXAMPLES = r'''
- name: Example usage
  my_namespace.my_collection.my_module:
    name: test
    state: present
'''

RETURN = r'''
message:
  description: Result message.
  returned: always
  type: str
'''

from ansible.module_utils.basic import AnsibleModule

def main():
    module = AnsibleModule(
        argument_spec=dict(
            name=dict(type='str', required=True),
            state=dict(type='str', default='present', choices=['present', 'absent']),
        ),
        supports_check_mode=True,
    )

    name = module.params['name']
    state = module.params['state']

    result = dict(changed=False, message=f'{name} is {state}')

    if module.check_mode:
        module.exit_json(**result)

    # Your logic here
    result['changed'] = True
    module.exit_json(**result)

if __name__ == '__main__':
    main()

Test Before Publishing

# Lint
ansible-lint roles/ plugins/

# Molecule (for roles)
cd roles/webserver
molecule test

# Sanity tests (for collections)
ansible-test sanity --docker

# Unit tests
ansible-test units --docker

# Integration tests
ansible-test integration --docker

Build and Publish

Build Collection

cd my_namespace/my_collection
ansible-galaxy collection build
# Creates my_namespace-my_collection-1.0.0.tar.gz

Publish to Galaxy

# Get API key from https://galaxy.ansible.com/me/preferences
ansible-galaxy collection publish \
  my_namespace-my_collection-1.0.0.tar.gz \
  --api-key YOUR_API_KEY

Publish to Private Automation Hub

ansible-galaxy collection publish \
  my_namespace-my_collection-1.0.0.tar.gz \
  --server https://hub.example.com/api/galaxy/content/inbound-collections/ \
  --api-key YOUR_PAH_TOKEN

Publish Role to Galaxy

# Role must be on GitHub
# Import via Galaxy web UI or:
ansible-galaxy role import github_username repo_name

Version Management

# Bump version in galaxy.yml
# Build
ansible-galaxy collection build

# Tag in git
git tag v1.1.0
git push --tags

# Publish
ansible-galaxy collection publish my_namespace-my_collection-1.1.0.tar.gz --api-key KEY

CI/CD Pipeline

# .github/workflows/release.yml
name: Release Collection
on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Ansible
        run: pip install ansible-core ansible-lint

      - name: Lint
        run: ansible-lint

      - name: Build
        run: ansible-galaxy collection build

      - name: Publish to Galaxy
        run: |
          ansible-galaxy collection publish \
            *.tar.gz \
            --api-key ${{ secrets.GALAXY_API_KEY }}

Best Practices

  1. Semantic versioning — major.minor.patch in galaxy.yml
  2. Test everything — Molecule for roles, ansible-test for collections
  3. Document variables — every default should be in README with description
  4. Use FQCN — ansible.builtin.copy not copy in all tasks
  5. Pin dependencies — specify minimum versions in galaxy.yml
  6. Changelog — maintain changelogs/changelog.yaml
  7. Validate before publish — ansible-lint + ansible-test sanity
  8. README with examples — users should be able to copy-paste and run

Conclusion

Creating and publishing Ansible roles and collections follows a clear workflow: scaffold with ansible-galaxy init, write tasks/modules, test with Molecule and ansible-lint, build with ansible-galaxy collection build, and publish with ansible-galaxy collection publish. For internal teams, publish to Private Automation Hub; for the community, publish to Galaxy. Every reusable piece of automation should be packaged as a role (simple) or collection (modules + roles + plugins).