Ansible Lesson 40 – Mini Project | Dataplexa
Section III · Lesson 40

Mini Project — End-to-End Ansible Automation

In this lesson

Project overview Project structure Roles & playbooks Deployment pipeline Validation checklist

This mini project is the capstone of the entire course. It ties together every concept from all three sections — inventory, playbooks, roles, variables, Vault, templates, handlers, error handling, tags, Galaxy, CI/CD, performance tuning, and security hardening — into a single cohesive project that provisions, configures, and deploys a real application stack. You will build it yourself, step by step, using the course material as your reference. The result is a production-grade Ansible project you can use as a template for real work.

Project Overview

What you are building: A three-tier web application stack — an Nginx reverse proxy, a Python/Django application server, and a PostgreSQL database — fully automated from bare server to running application, with a GitHub Actions CI/CD pipeline that lints, tests, and deploys on every push to main.

Internet port 443 Nginx role: nginx reverse proxy · SSL webservers group Django App role: app_deploy gunicorn · port 8000 appservers group PostgreSQL role: postgresql port 5432 · internal only databases group

Project requirements

1

Four roles: common, nginx, postgresql, app_deploy — each independently testable with Molecule

2

All secrets encrypted with Ansible Vault — no plaintext credentials anywhere in the repository

3

Separate staging and production inventories — same playbooks, different variable values

4

Symlink release management with automated rollback via block/rescue

5

GitHub Actions pipeline: lint → Molecule → deploy staging → manual gate → deploy production

6

ansible.cfg with pipelining enabled, forks ≥ 20, fact caching, and profile_tasks

Project Structure

ansible-miniproject/
├── ansible.cfg                        # pipelining, forks, fact caching
├── site.yml                           # provision: all roles, all tiers
├── deploy.yml                         # deploy only: app_deploy role
├── smoke_tests.yml                    # post-deploy health verification
├── requirements.yml                   # Galaxy deps (geerlingguy.certbot etc.)
│
├── inventory/
│   ├── staging/
│   │   ├── hosts.ini
│   │   └── group_vars/
│   │       ├── all.yml                # env: staging
│   │       ├── webservers.yml
│   │       ├── appservers.yml
│   │       └── databases.yml
│   └── production/
│       ├── hosts.ini
│       └── group_vars/
│           ├── all.yml                # env: production
│           ├── webservers.yml
│           ├── appservers.yml
│           └── databases.yml
│
├── roles/
│   ├── common/                        # base packages, timezone, NTP, motd
│   ├── nginx/                         # reverse proxy + SSL
│   ├── postgresql/                    # database server
│   └── app_deploy/                    # symlink release deployment
│
├── vars/
│   └── secrets.yml                    # vault-encrypted (all credentials)
│
├── .github/
│   └── workflows/
│       └── ansible.yml                # full CI/CD pipeline
│
├── .ansible-lint                      # lint configuration
├── .vault_pass                        # NOT committed (.gitignore)
├── .gitignore                         # includes .vault_pass, *.retry
└── README.md

Core Configuration Files

ansible.cfg

[defaults]
inventory               = ./inventory/staging
remote_user             = ansible
forks                   = 20
gathering               = smart
fact_caching            = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout    = 3600
callbacks_enabled       = timer, profile_tasks
stdout_callback         = yaml

[ssh_connection]
pipelining              = True
ssh_args                = -C -o ControlMaster=auto -o ControlPersist=60s

site.yml — full provisioning

---
- name: Apply common baseline to all hosts
  hosts: all
  become: true
  roles:
    - role: common
      tags: [common, always]

- name: Configure web tier
  hosts: webservers
  become: true
  roles:
    - role: nginx
      tags: [nginx, config]

- name: Configure database tier
  hosts: databases
  become: true
  roles:
    - role: postgresql
      tags: [postgresql, config]

- name: Deploy application
  hosts: appservers
  become: true
  roles:
    - role: app_deploy
      tags: [deploy]

deploy.yml — deployment only (symlink + rollback)

---
- name: Deploy application release
  hosts: appservers
  become: true
  serial: 1
  max_fail_percentage: 0

  vars_files:
    - vars/secrets.yml

  pre_tasks:
    - name: Assert version is provided
      ansible.builtin.assert:
        that: app_version is defined and app_version | length > 0
        fail_msg: "Pass -e app_version=X.Y.Z"

  tasks:
    - name: Deploy with automatic rollback
      block:
        - name: Create release directory
          ansible.builtin.file:
            path: "{{ deploy_dir }}/releases/{{ app_version }}"
            state: directory
            owner: "{{ app_user }}"
            mode: "0755"

        - name: Extract release archive
          ansible.builtin.unarchive:
            src: "dist/app-{{ app_version }}.tar.gz"
            dest: "{{ deploy_dir }}/releases/{{ app_version }}"
            owner: "{{ app_user }}"

        - name: Link shared config
          ansible.builtin.file:
            src: "{{ deploy_dir }}/shared/config"
            dest: "{{ deploy_dir }}/releases/{{ app_version }}/config"
            state: link

        - name: Run database migrations
          ansible.builtin.command:
            cmd: python manage.py migrate --no-input
            chdir: "{{ deploy_dir }}/releases/{{ app_version }}"
          become_user: "{{ app_user }}"
          changed_when: true

        - name: Update current symlink
          ansible.builtin.file:
            src: "{{ deploy_dir }}/releases/{{ app_version }}"
            dest: "{{ deploy_dir }}/current"
            state: link
            force: true
          notify: app_deploy | Restart application

        - name: Flush handlers before health check
          ansible.builtin.meta: flush_handlers

        - name: Health check
          ansible.builtin.uri:
            url: "http://{{ ansible_default_ipv4.address }}:8000/health"
            status_code: 200
          retries: 10
          delay: 6
          until: health.status == 200
          register: health

      rescue:
        - name: Rollback to previous release
          ansible.builtin.shell: |
            prev=$(ls -1dt {{ deploy_dir }}/releases/*/ | sed -n '2p')
            ln -sfn "$prev" {{ deploy_dir }}/current
          notify: app_deploy | Restart application

        - name: Fail after rollback
          ansible.builtin.fail:
            msg: "Deployment of {{ app_version }} FAILED — rolled back."

      always:
        - name: Prune old releases
          ansible.builtin.shell: |
            ls -1dt {{ deploy_dir }}/releases/*/ | tail -n +6 | xargs rm -rf
          changed_when: false

Secrets and Variables

vars/secrets.yml — before encryption

---
# ansible-vault encrypt vars/secrets.yml before committing
vault_db_password: "change-me-in-production"
vault_app_secret_key: "django-secret-key-here"
vault_registry_password: "registry-token-here"
# Encrypt before first commit
ansible-vault encrypt vars/secrets.yml

# Edit later
ansible-vault edit vars/secrets.yml

inventory/staging/group_vars/databases.yml

---
postgresql_version: "15"
postgresql_port: 5432
postgresql_max_connections: 50
postgresql_databases:
  - name: appdb_staging
    owner: appuser
postgresql_users:
  - name: appuser
    password: "{{ vault_db_password }}"
    db: appdb_staging

CI/CD Pipeline

The pipeline enforces all four gates — lint passes before Molecule runs, Molecule passes before staging deploys, staging passes before production is available for approval.

# .github/workflows/ansible.yml
name: Ansible CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:

  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ansible ansible-lint yamllint
      - run: ansible-galaxy install -r requirements.yml
      - run: yamllint .
      - run: ansible-lint
      - run: ansible-playbook site.yml --syntax-check -i inventory/staging/

  molecule:
    runs-on: ubuntu-latest
    needs: validate
    strategy:
      matrix:
        role: [common, nginx, postgresql, app_deploy]
    steps:
      - uses: actions/checkout@v4
      - run: pip install ansible molecule molecule-plugins[docker]
      - run: cd roles/${{ matrix.role }} && molecule test

  deploy-staging:
    runs-on: ubuntu-latest
    needs: molecule
    if: github.event_name == 'push'
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - run: pip install ansible
      - run: ansible-galaxy install -r requirements.yml
      - run: echo "${{ secrets.VAULT_PASSWORD }}" > .vault_pass
      - run: echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
      - run: |
          ansible-playbook site.yml -i inventory/staging/ \
            --vault-password-file .vault_pass --check --diff
          ansible-playbook site.yml -i inventory/staging/ \
            --vault-password-file .vault_pass
          ansible-playbook smoke_tests.yml -i inventory/staging/ \
            --vault-password-file .vault_pass
      - if: always()
        run: rm -f .vault_pass ~/.ssh/id_ed25519

  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: pip install ansible
      - run: ansible-galaxy install -r requirements.yml
      - run: echo "${{ secrets.VAULT_PASSWORD_PROD }}" > .vault_pass
      - run: echo "${{ secrets.SSH_KEY_PROD }}" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
      - run: |
          ansible-playbook site.yml -i inventory/production/ \
            --vault-password-file .vault_pass
          ansible-playbook smoke_tests.yml -i inventory/production/ \
            --vault-password-file .vault_pass
      - if: always()
        run: rm -f .vault_pass ~/.ssh/id_ed25519

Step-by-Step Build Sequence

Follow this order. Each step is testable before moving to the next — never write all files at once and debug everything together.

1

Initialise project structure

Create directory layout, ansible.cfg, .gitignore, .ansible-lint. Run ansible-galaxy role init for each of the four roles.

2

Write and test the common role

Base packages, timezone, NTP. Run molecule test in roles/common/. Green Molecule before moving on.

3

Write and test the nginx role

Install, configure from template, manage vhosts. All configurable options in defaults/. Handler prefixed nginx |. Molecule green.

4

Write and test the postgresql role

Install, configure, create database and user. no_log: true on user task. Tasks split across install.yml, configure.yml, databases.yml. Molecule green.

5

Write the app_deploy role

Symlink release pattern with block/rescue rollback, health check, old release pruning. This is the core of deploy.yml.

6

Build the inventory and variable files

Staging and production inventories. All secrets in vars/secrets.yml — encrypt with Vault. Verify with ansible-inventory --graph.

7

Run site.yml against staging — first full run

Run with --check --diff first. Review output. Apply. Confirm all tasks are idempotent on the second run (changed=0).

8

Write the GitHub Actions pipeline

Store vault password and SSH key as repository secrets. Add the workflow file. Push to a branch and confirm all four jobs run and pass.

9

Test rollback

Deploy a version with a deliberately broken health endpoint. Confirm the rescue block fires, the previous release symlink is restored, and the application comes back healthy.

Completion Checklist

Use this checklist to verify your project meets every requirement before considering it complete. Every item maps to a lesson from this course.

ansible-lint passes with zero violations — all tasks named, FQCN modules used, no shell where a module exists (Lessons 10, 25, 39)
Molecule tests pass for all four roles — each role works in isolation before integration (Lesson 10)
Second run of site.yml reports changed=0 — full idempotency confirmed (Lessons 1, 13)
ansible-vault view vars/secrets.yml prompts for password — no plaintext secrets in any committed file (Lessons 28, 29)
Rollback triggers automatically when health check fails — rescue block restores previous release and service comes back healthy (Lessons 21, 30)
Staging and production use different variable values from group_vars — same playbooks, different environments (Lessons 6, 7, 24)
GitHub Actions pipeline is green end-to-end — lint, Molecule, staging, manual gate, production all pass (Lessons 37, 38)
profile_tasks output shows Gathering Facts is not the slowest step — pipelining and fact caching are effective (Lessons 25, 35)
git log --all shows .vault_pass was never committed — no credential has ever touched version control (Lessons 28, 39)

Course Complete

You have completed the Dataplexa Ansible Course

Across 40 lessons you have gone from Ansible fundamentals to production-grade automation — writing idempotent playbooks, building reusable roles, managing secrets securely, deploying applications with automated rollback, hardening servers, managing Docker and Kubernetes, and shipping automation through a full CI/CD pipeline. The mini project in this lesson is proof of that capability. Keep it as a living template — every real project you build from here will reuse these patterns.

40

Lessons completed

3

Sections mastered

1

Production-ready project