How to Design an End-to-End Ansible Automation Lab with Playbooks, Inventories, Roles, Vault, Dynamic Inventory, and Custom Modules

In this tutorial, we build a complete Ansible lab that runs end-to-end in Google Colab or any Linux environment. We start by…

By AI Maestro May 29, 2026 9 min read
How to Design an End-to-End Ansible Automation Lab with Playbooks, Inventories, Roles, Vault, Dynamic Inventory, and Custom Modules

In this tutorial, we build a complete Ansible lab that runs end-to-end in Google Colab or any Linux environment. We start by installing ansible-core, setting up a local workspace, creating an Ansible configuration file, and defining both static and dynamic inventories. We then explore key Ansible concepts, including group variables, host variables, variable precedence, ad hoc commands, playbooks, loops, conditionals, registered outputs, facts, templates, custom filters, custom modules, roles, handlers, tags, dry runs, idempotency, and Ansible Vault. Since every host runs locally, we practice these concepts safely without needing SSH keys, remote servers, or cloud infrastructure.

import os, sys, subprocess, textwrap, stat
BASE = "/content/ansible_lab" if os.path.isdir("/content") else os.path.expanduser("~/ansible_lab")
os.makedirs(BASE, exist_ok=True)
ENV = os.environ.copy()
ENV["ANSIBLE_CONFIG"]      = os.path.join(BASE, "ansible.cfg")
ENV["ANSIBLE_FORCE_COLOR"] = "1"
ENV["PY_COLORS"]           = "0"
def banner(title):
   print("\n" + "=" * 78 + f"\n  {title}\n" + "=" * 78)
def write(relpath, content):
   """Write a dedented file under BASE, creating parent dirs."""
   path = os.path.join(BASE, relpath)
   os.makedirs(os.path.dirname(path), exist_ok=True)
   with open(path, "w") as f:
       f.write(textwrap.dedent(content).lstrip("\n"))
   return path
def sh(cmd, title=None):
   """Run a shell command from BASE, stream stdout, never raise."""
   if title:
       banner(title)
   print(f"$ {cmd}\n")
   p = subprocess.run(cmd, shell=True, cwd=BASE, env=ENV,
                      stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
   print(p.stdout)
   return p.returncode
banner("STEP 1 — Installing ansible-core")
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "ansible-core"], check=True)
sh("ansible --version")
write("ansible.cfg", """
   [defaults]
   inventory              = ./inventory.ini
   roles_path             = ./roles
   library                = ./library
   filter_plugins         = ./filter_plugins
   vault_password_file    = ./vault_pass.txt
   host_key_checking      = False
   retry_files_enabled    = False
   interpreter_python     = auto_silent
   callback_result_format = yaml
   deprecation_warnings   = False
   localhost_warning      = False
   nocows                 = 1
   [privilege_escalation]
   become = False
""")
write("inventory.ini", """
   [webservers]
   web1 ansible_connection=local
   web2 ansible_connection=local
   [dbservers]
   db1 ansible_connection=local
   [datacenter:children]
   webservers
   dbservers
""")

We start by preparing the Ansible workspace, setting environment variables, and defining helper functions that make the tutorial easier to run. We install ansible-core, verify the installation, and create the main Ansible configuration file. We also define a static inventory with local web and database host groups so that we can practice Ansible concepts without using remote servers.

write("group_vars/all.yml", """
   ---
   app_name: "Colab Demo App"
   app_version: "2.0.1"
   admin_email: "ad***@*****le.com"
   packages:
     - nginx
     - git
     - htop
   feature_flags:
     enable_cache: true
     enable_metrics: false
""")
write("host_vars/web1.yml", """
   ---
   server_id: 101
   max_connections: 512
""")
write("filter_plugins/custom_filters.py", '''
   import re
   def to_slug(value):
       return re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip("-")
   def human_bytes(value):
       n = float(value)
       for unit in ["B", "KB", "MB", "GB", "TB"]:
           if n < 1024:
               return f"{n:.1f}{unit}"
           n /= 1024
       return f"{n:.1f}PB"
   class FilterModule(object):
       def filters(self):
           return {"to_slug": to_slug, "human_bytes": human_bytes}
''')
write("library/system_report.py", '''
   #!/usr/bin/python
   from ansible.module_utils.basic import AnsibleModule
   import platform, os
   def main():
       module = AnsibleModule(
           argument_spec=dict(
               label=dict(type="str", required=True),
               threshold=dict(type="int", required=False, default=80),
           ),
           supports_check_mode=True,
       )
       report = {
           "label": module.params["label"],
           "system": platform.system(),
           "release": platform.release(),
           "python": platform.python_version(),
           "cpu_count": os.cpu_count(),
           "threshold": module.params["threshold"],
       }
       module.exit_json(changed=False,
                        report=report,
                        message="Report generated for %s" % module.params["label"])
   if __name__ == "__main__":
       main()
''')

We define shared group variables and host-specific variables to show how Ansible manages configuration data and applies variable precedence. We then create a custom Jinja2 filter plugin that converts text into slugs and formats byte values into readable units. We also built a custom Python-based Ansible module that generates a simple system report for each host.

write("roles/webserver/defaults/main.yml", """
   ---
   listen_port: 8080
""")
write("roles/webserver/vars/main.yml", """
   ---
   doc_root: "/tmp/www"
""")
write("roles/webserver/tasks/main.yml", """
   ---
   - name: Ensure docroot exists
     ansible.builtin.file:
       path: "{{ doc_root }}"
       state: directory
       mode: "0755"
   - name: Deploy index.html from a Jinja2 template
     ansible.builtin.template:
       src: index.html.j2
       dest: "{{ doc_root }}/index.html"
     notify: Restart web service
   - name: Run handlers immediately (instead of end of play)
     ansible.builtin.meta: flush_handlers
""")
write("roles/webserver/handlers/main.yml", """
   ---
   - name: Restart web service
     ansible.builtin.debug:
       msg: "(simulated) restarting web service on port {{ listen_port }}"
""")
write("roles/webserver/templates/index.html.j2", """
   <!DOCTYPE html>
   <html>
     <head><title>{{ app_name }}</title></head>
     <body>
       <h1>{{ app_name }} v{{ app_version }}</h1>
       <p>Served on port {{ listen_port }} from {{ doc_root }}</p>
       <p>Host: {{ inventory_hostname }}</p>
     </body>
   </html>
""")
write("templates/report.txt.j2", """
   Deployment Report
   =================
   App:        {{ app_name }} ({{ app_version }})
   Host:       {{ inventory_hostname }}
   Generated:  {{ ansible_date_time.iso8601 | default('n/a') }}
   Slug:       {{ app_name | to_slug }}
   Packages:
   {% for p in packages %}
     - {{ p }}
   {% endfor %}
   Cache enabled:   {{ feature_flags.enable_cache }}
   Metrics enabled: {{ feature_flags.enable_metrics }}
""")
dyn = write("dynamic_inventory.py", '''
   #!/usr/bin/env python3
   import json, sys
   INV = {
       "webservers": {"hosts": ["web1", "web2"], "vars": {"role": "frontend"}},
       "dbservers":  {"hosts": ["db1"],          "vars": {"role": "backend"}},
       "_meta": {
           "hostvars": {
               "web1": {"ansible_connection": "local", "tier": "gold"},
               "web2": {"ansible_connection": "local", "tier": "silver"},
               "db1":  {"ansible_connection": "local", "tier": "gold"},
           }
       },
   }
   if "--host" in sys.argv:
       print(json.dumps({}))
   else:
       print(json.dumps(INV, indent=2))
''')
os.chmod(dyn, os.stat(dyn).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)

We create a complete web server role with defaults, variables, tasks, handlers, and templates to demonstrate how to build reusable Ansible automation. We use Jinja2 templates to generate an HTML page and a deployment report from Ansible variables. We also add a dynamic inventory script that returns host and group information in JSON format.

write("playbook.yml", """
   ---
   - name: Advanced concepts demo
     hosts: webservers
     gather_facts: true
     vars:
       deploy_user: colab
     tasks:
       - name: Merged variables (group_vars + host_vars precedence)
         ansible.builtin.debug:
           msg: "App={{ app_name }} v{{ app_version }} | server_id={{ server_id | default('n/a') }}"
       - name: CUSTOM filter -> to_slug
         ansible.builtin.debug:
           msg: "slug => {{ app_name | to_slug }}"
       - name: CUSTOM filter -> human_bytes
         ansible.builtin.debug:
           msg: "size => {{ 1536000 | human_bytes }}"
       - name: LOOP with an index variable
         ansible.builtin.debug:
           msg: "package #{{ idx + 1 }} = {{ item }}"
         loop: "{{ packages }}"
         loop_control:
           index_var: idx
       - name: CONDITIONAL (when) — only if caching is enabled
         ansible.builtin.debug:
           msg: "cache is ON"
         when: feature_flags.enable_cache | bool
       - name: Run a command and REGISTER its output
         ansible.builtin.command: date +%Y-%m-%d
         register: date_out
         changed_when: false
       - name: SET a derived fact from the registered value
         ansible.builtin.set_fact:
           deploy_stamp: "{{ app_name | to_slug }}-{{ date_out.stdout }}"
       - name: Show the derived fact
         ansible.builtin.debug:
           var: deploy_stamp
       - name: Run our CUSTOM MODULE (system_report)
         system_report:
           label: "{{ inventory_hostname }}"
           threshold: 90
         register: sysrep
       - name: Show custom module output
         ansible.builtin.debug:
           var: sysrep.report
       - name: BLOCK with rescue/always (error handling)
         block:
           - name: This fails on purpose
             ansible.builtin.command: /bin/false
           - name: Never reached
             ansible.builtin.debug:
               msg: "unreachable"
         rescue:
           - name: Recover gracefully
             ansible.builtin.debug:
               msg: "caught the failure — recovering"
         always:
           - name: Always run cleanup
             ansible.builtin.debug:
               msg: "cleanup runs no matter what"
       - name: Use a VAULT-encrypted secret (decrypted at runtime)
         ansible.builtin.debug:
           msg: "token prefix={{ api_secret_token[:3] }}*** len={{ api_secret_token | length }}"
       - name: TEMPLATE a report file (tagged 'report')
         ansible.builtin.template:
           src: report.txt.j2
           dest: "/tmp/{{ inventory_hostname }}_report.txt"
         tags: [report]
   - name: Role demo
     hosts: web1
     gather_facts: false
     roles:
       - role: webserver
""")

We write the main playbook that brings together variables, custom filters, loops, conditionals, registered outputs, derived facts, and a custom module. We intentionally include a failing command to demonstrate error handling through block, rescue, and always. We also use a Vault-encrypted secret and apply the web server role to demonstrate how role-based automation works in a real workflow.

banner("STEP 2 — Ansible Vault: encrypting an inline secret")
write("vault_pass.txt", "colab-demo-vault-pass\n")
enc = subprocess.run(
   "ansible-vault encrypt_string 'S3cr3t-Token-42' --name 'api_secret_token'",
   shell=True, cwd=BASE, env=ENV, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
).stdout
with open(os.path.join(BASE, "group_vars/webservers.yml"), "w") as f:
   f.write("---\n")
   f.write(enc)
print("group_vars/webservers.yml now contains:\n")
print(open(os.path.join(BASE, "group_vars/webservers.yml")).read())
sh("ansible-inventory -i inventory.ini --graph",      "STEP 3 — Static inventory graph")
sh("ansible-inventory -i dynamic_inventory.py --list", "STEP 4 — Dynamic inventory (JSON)")
sh("ansible all -m ping",                 "STEP 5 — Ad-hoc: ping all hosts")
sh("ansible web1 -m setup -a 'filter=ansible_python_version'",
  "STEP 6 — Ad-hoc: gather a single fact")

We create a Vault password file and encrypt an inline secret that Ansible decrypts automatically when the playbook runs. We inspect both the static and dynamic inventories to understand how Ansible reads hosts, groups, and metadata. We then run ad-hoc commands to ping all hosts and gather a specific Python version fact from web1.

sh("ansible-playbook playbook.yml --check --diff", "STEP 7 — Dry run (--check)")
sh("ansible-playbook playbook.yml",                "STEP 8 — Real run")
sh("ansible-playbook playbook.yml",                "STEP 9 — Re-run (idempotency: expect 0 changed)")
sh("ansible-playbook playbook.yml --tags report",  "STEP 10 — Run only tasks tagged 'report'")
sh("echo '--- /tmp/www/index.html ---'; cat /tmp/www/index.html; "
  "echo; echo '--- /tmp/web1_report.txt ---'; cat /tmp/web1_report.txt",
  "STEP 11 — Generated files")
sh('ansible webservers --limit web1 -m debug -a "var=api_secret_token"',
  "STEP 12a — Inline vault secret decrypted at runtime")
write("secrets.yml", """
   ---
   db_password: full-file-secret-99
   api_key: abc123
""")
sh("ansible-vault encrypt secrets.yml",        "STEP 12b — Encrypt a WHOLE file")
sh("head -c 60 secrets.yml; echo ' ...'")
sh("ansible-vault view secrets.yml",           "STEP 12c — View the fully-encrypted file")
banner("DONE — you now have a working advanced Ansible lab in Colab")
print(f"Workspace: {BASE}\nEdit any file there and re-run a step with the sh() helper.")

We run the playbook in check mode, execute it for real, and rerun it to confirm that the workflow is idempotent. We use tags to run only the report-related task and then inspect the generated HTML and text report files. We also demonstrate full-file Vault encryption, safely view the encrypted file, and complete the advanced Ansible lab.

In conclusion, we have a working Ansible lab that demonstrates how automation workflows are structured and executed in real projects. We created reusable roles, generated files from Jinja2 templates, ran custom Python-based Ansible modules, handled errors with rescue and always blocks, encrypted secrets with Ansible Vault, and validated our setup through dry runs and repeated idempotent executions. We also learned how static and dynamic inventories work, how tags help us run selected tasks, and how Ansible organizes infrastructure automation in a clean, repeatable, and production-friendly way.


Check out the Full Codes with Notebook hereAlso, feel free to follow us on Twitter and don’t forget to join our 150k+ ML SubReddit and Subscribe to our Newsletter. Wait! are you on telegram? now you can join us on telegram as well.

Need to partner with us for promoting your GitHub Repo OR Hugging Face Page OR Product Release OR Webinar etc.? Connect with us

The post How to Design an End-to-End Ansible Automation Lab with Playbooks, Inventories, Roles, Vault, Dynamic Inventory, and Custom Modules appeared first on MarkTechPost.

Stay ahead of AI. Get the most important stories delivered to your inbox — no spam, no noise.

Name
Scroll to Top