A free, guided curriculum

Learn Ansible by
automating a real server.

Seven phases, start to finish: ad-hoc commands, playbooks, variables and Jinja2 templates, conditionals and handlers, reusable roles, encrypted secrets with Vault, and a dynamic, cloud-aware inventory tied into the pipeline you already built.

Playbook Run

01 Core Concepts & Setup
02 Playbooks Basics
03 Variables, Facts & Templates
04 Conditionals, Loops & Handlers
05 Roles & Project Structure
06 Ansible Vault & Security
07 Production Practices

Phase 1 of 7

Core Concepts & Setup

Agentless architecture, inventory files, and ad-hoc commands.

Goal of this phase: Understand Ansible's agentless architecture, get it installed, build your first inventory, and run ad-hoc commands against real servers.

Builds on your Linux skills (especially SSH from Linux Phase 5) and connects directly to the Ansible work you already did in Jenkins Phase 6.


1. What is Ansible? (Concept)

Ansible is a configuration management and automation tool — it configures servers, installs software, and deploys applications, all described in human-readable YAML instead of scripted by hand.

Agentless architecture — the key differentiator

Unlike some competitors (Puppet, Chef), Ansible requires no agent software installed on the machines it manages. It connects over plain SSH (or WinRM for Windows), runs what it needs to, and leaves nothing behind.

shell
┌─────────────────┐         SSH          ┌──────────────────┐
│  Control Node    │ ──────────────────►  │  Managed Node 1   │
│  (your laptop /  │ ──────────────────►  │  Managed Node 2   │
│   a CI server)   │ ──────────────────►  │  Managed Node 3   │
└─────────────────┘                      └──────────────────┘
Term Meaning
Control node The machine where Ansible itself is installed and run from
Managed node A remote server being configured — needs only SSH access and Python, nothing Ansible-specific pre-installed
Inventory A file listing which servers Ansible should manage, and how they're grouped
Module A unit of work Ansible can perform (install a package, copy a file, start a service)
Playbook A YAML file describing a series of tasks to run (Phase 2 goes deep on this)
Ad-hoc command A one-off Ansible command run directly from the terminal, no playbook needed

Why this matters for you: this is the exact tool you already used in Jenkins Phase 6 to configure a Terraform-provisioned EC2 instance. This curriculum goes far deeper into everything that guide only touched briefly.


2. Installation

bash
# On your control node (can be your Jenkins agent, your own machine, or any Linux box)
sudo yum install -y ansible          # Amazon Linux / RHEL
# or
sudo apt update && sudo apt install -y ansible    # Ubuntu/Debian

ansible --version

Prerequisite: SSH access to your managed nodes

Ansible needs to SSH into each managed node. This is exactly the SSH key setup from Linux Phase 5 — if you can already ssh -i mykey.pem ec2-user@<ip> successfully, Ansible can use that same access.

bash
# Test manually first - if this works, Ansible will work too
ssh -i mykey.pem ec2-user@<managed-node-ip>

Prerequisite: Python on managed nodes

Ansible modules are executed as Python scripts on the managed node. Most modern Linux distros (including Amazon Linux) ship with Python already installed. Verify:

bash
ssh -i mykey.pem ec2-user@<managed-node-ip> "python3 --version"

3. Inventory Files (Concept + Hands-On)

The inventory tells Ansible which servers exist and how they're organized.

Basic INI-format inventory

Create inventory.ini:

ini
[webservers]
web1 ansible_host=54.123.45.67 ansible_user=ec2-user ansible_ssh_private_key_file=~/mykey.pem
web2 ansible_host=54.123.45.68 ansible_user=ec2-user ansible_ssh_private_key_file=~/mykey.pem

[dbservers]
db1 ansible_host=54.123.45.69 ansible_user=ec2-user ansible_ssh_private_key_file=~/mykey.pem

[production:children]
webservers
dbservers
  • [webservers] and [dbservers] are groups — you can target commands at a whole group at once
  • [production:children] creates a group of groups — targeting production hits both webservers and dbservers
  • Each host can have its own connection variables (ansible_host, ansible_user, etc.), so you don't need to specify them on every command

YAML-format inventory (equivalent, increasingly common)

inventory.yml:

yaml
all:
  children:
    webservers:
      hosts:
        web1:
          ansible_host: 54.123.45.67
        web2:
          ansible_host: 54.123.45.68
      vars:
        ansible_user: ec2-user
        ansible_ssh_private_key_file: ~/mykey.pem
    dbservers:
      hosts:
        db1:
          ansible_host: 54.123.45.69
      vars:
        ansible_user: ec2-user
        ansible_ssh_private_key_file: ~/mykey.pem

Notice vars at the group level applies to every host in that group — avoiding repeating the same connection details on every single host line.

Verify your inventory

bash
ansible-inventory -i inventory.ini --list
ansible-inventory -i inventory.ini --graph

4. Ad-Hoc Commands (Concept + Hands-On)

Ad-hoc commands run a single module against your inventory, with no playbook file needed — perfect for quick one-off tasks and testing connectivity.

bash
# The most important first command you'll ever run - test connectivity
ansible all -i inventory.ini -m ping

# Run a raw shell command
ansible webservers -i inventory.ini -m shell -a "uptime"

# Check disk space across a whole group
ansible webservers -i inventory.ini -m shell -a "df -h"

# Install a package (requires sudo - Ansible calls this "become")
ansible webservers -i inventory.ini -m yum -a "name=htop state=present" --become

# Copy a file to remote hosts
ansible webservers -i inventory.ini -m copy -a "src=./notes.txt dest=/tmp/notes.txt"

# Target a SINGLE host instead of a whole group
ansible web1 -i inventory.ini -m ping

# Target everything
ansible all -i inventory.ini -m ping

Understanding the ping module's response

shell
web1 | SUCCESS => {
    "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3" },
    "changed": false,
    "ping": "pong"
}

Note "changed": false — this appears constantly in Ansible output and is central to how it thinks: Ansible reports whether a task actually changed something on the target, not just whether it ran successfully. A ping never changes anything, so it's always false. You'll see this concept much more in Phase 2 onward.

The --become flag

Many operations (installing packages, managing services) need root privileges on the managed node. --become tells Ansible to use sudo (or an equivalent) when executing — directly analogous to prefixing a command with sudo yourself, but applied remotely.


5. Hands-On Mini-Project: Provision a Basic Web Server via Ad-Hoc Commands

Using a single EC2 instance (or reuse one from your Jenkins/Terraform work):

bash
# 1. Confirm connectivity
ansible all -i inventory.ini -m ping

# 2. Install nginx
ansible webservers -i inventory.ini -m yum -a "name=nginx state=present" --become

# 3. Start and enable it
ansible webservers -i inventory.ini -m service -a "name=nginx state=started enabled=yes" --become

# 4. Deploy a simple page
ansible webservers -i inventory.ini -m copy -a "content='<h1>Hello from Ansible ad-hoc commands</h1>' dest=/usr/share/nginx/html/index.html" --become

# 5. Verify
curl http://<managed-node-ip>

Notice this is exactly what a playbook would do (and exactly what you did with a full playbook in Jenkins Phase 6) — just expressed as individual commands instead of one reusable file. That distinction is precisely what Phase 2 addresses.


6. Checkpoint — Can You Answer These?

  1. What does "agentless" mean, and why does that make Ansible's prerequisites simpler than some alternatives?
  2. What's the difference between a control node and a managed node?
  3. In an inventory file, what does [production:children] do?
  4. What does --become do, and when do you need it?
  5. What does "changed": false in ad-hoc command output actually tell you?
  6. Why would you eventually want a playbook instead of a sequence of ad-hoc commands?

If you can answer all six, you're ready for Phase 2: Playbooks Basics — turning those ad-hoc commands into a single, reusable, version-controlled file.


Quick Reference — This Phase

bash
ansible --version
ansible-inventory -i inventory.ini --list
ansible-inventory -i inventory.ini --graph

ansible all -i inventory.ini -m ping
ansible <group> -i inventory.ini -m <module> -a "<args>" --become
ini
[webservers]
web1 ansible_host=<ip> ansible_user=ec2-user ansible_ssh_private_key_file=~/mykey.pem

[production:children]
webservers

Next: Phase 2 — Playbooks Basics. Ask when ready.

Start of curriculum

Phase 2 of 7

Playbooks Basics

YAML structure, common modules, dry runs, and variables.

Goal of this phase: Turn one-off ad-hoc commands into a reusable, version-controlled playbook — the standard way real Ansible work gets done.

Builds directly on Phase 1's inventory and ad-hoc commands.


1. YAML Syntax Refresher (Concept)

Playbooks are written in YAML. A few rules that matter enormously here, since whitespace errors are the #1 source of playbook frustration:

  • Indentation matters — use spaces, never tabs. Consistent 2-space indentation is the near-universal convention.
  • Lists use -: ```yaml fruits:
    • apple
    • banana ```
  • Key-value pairs use :: yaml name: web1 state: present
  • Strings usually don't need quotes, but quote anything with special characters (:, {, #) or that could be misread as another type (e.g., a version number that looks numeric).

2. Anatomy of a Playbook (Concept)

yaml
---
- name: Configure web servers
  hosts: webservers
  become: true

  tasks:
    - name: Install nginx
      yum:
        name: nginx
        state: present

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

    - name: Deploy the homepage
      copy:
        content: "<h1>Configured by an Ansible playbook</h1>"
        dest: /usr/share/nginx/html/index.html
Key Meaning
- name: (top level) Describes this play — one playbook can contain multiple plays
hosts: Which inventory group/host this play targets
become: Equivalent to --become on the command line — use root/sudo for this play
tasks: The ordered list of things to do
- name: (task level) Human-readable description of the task, shown in output
yum: / service: / copy: The module being used for this task, with its arguments indented beneath

This is precisely the playbook structure from your Jenkins Phase 6 guide — now you're building the understanding behind it rather than just copying it.


3. Common Modules (Concept + Hands-On)

Module Purpose
yum / apt Install/remove packages (use the one matching your target OS)
package OS-agnostic package installer — picks yum or apt automatically
copy Copy a file (or inline content) to the remote host
template Like copy, but processes the file through Jinja2 first (Phase 3)
service Start/stop/enable a systemd (or other init) service
file Create directories, set permissions/ownership, create symlinks
user Create/manage user accounts
lineinfile Ensure a specific line exists (or doesn't) in a file, without rewriting the whole thing
git Clone/update a git repository

Hands-On: Write Your First Playbook

site.yml:

yaml
---
- name: Set up a basic web server
  hosts: webservers
  become: true

  tasks:
    - name: Install nginx
      yum:
        name: nginx
        state: present

    - name: Ensure the web directory exists with correct permissions
      file:
        path: /usr/share/nginx/html
        state: directory
        mode: "0755"

    - name: Deploy homepage
      copy:
        content: "<h1>Hello from my first Ansible playbook</h1>"
        dest: /usr/share/nginx/html/index.html

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

Run it:

bash
ansible-playbook -i inventory.ini site.yml

Read the output carefully — for each task, Ansible reports ok (nothing needed to change), changed (it made a change), failed (something went wrong), or skipped.


4. Dry Runs and Verbosity (Concept + Hands-On)

--check — see what WOULD happen, without actually doing it

bash
ansible-playbook -i inventory.ini site.yml --check

This runs the playbook in simulation mode — genuinely valuable before applying changes to anything you care about, especially once playbooks get more complex.

--diff — show exactly what would change in files

bash
ansible-playbook -i inventory.ini site.yml --check --diff

Verbosity levels — debugging when something isn't behaving as expected

bash
ansible-playbook -i inventory.ini site.yml -v         # basic verbose
ansible-playbook -i inventory.ini site.yml -vvv           # very verbose - shows the actual module arguments sent

5. Variables (Concept + Hands-On)

Inline vars in a playbook

yaml
---
- name: Set up a basic web server
  hosts: webservers
  become: true
  vars:
    web_package: nginx
    web_root: /usr/share/nginx/html

  tasks:
    - name: Install web server
      yum:
        name: "{{ web_package }}"
        state: present

    - name: Deploy homepage
      copy:
        content: "<h1>Hello from {{ web_package }}</h1>"
        dest: "{{ web_root }}/index.html"

Notice the {{ variable_name }} syntax — this is Jinja2 templating, and it works both inside playbooks directly and inside actual template files (Phase 3 goes much deeper).

External variable files

For larger projects, pull variables out into their own file:

vars.yml:

yaml
web_package: nginx
web_root: /usr/share/nginx/html

Reference it from the playbook:

yaml
---
- name: Set up a basic web server
  hosts: webservers
  become: true
  vars_files:
    - vars.yml

  tasks:
    - name: Install web server
      yum:
        name: "{{ web_package }}"
        state: present

When to use which: inline vars for a handful of values specific to one playbook; vars_files once you have enough variables that they clutter the playbook, or when the same variables need to be shared across multiple playbooks.


6. Hands-On Project: Multi-Task Playbook with Variables

Extend the web server playbook into something closer to real use:

yaml
---
- name: Provision and configure a web server
  hosts: webservers
  become: true
  vars:
    app_name: my-practice-app
    app_port: 8080

  tasks:
    - name: Install required packages
      yum:
        name:
          - nginx
          - firewalld
        state: present

    - name: Ensure firewalld is running
      service:
        name: firewalld
        state: started
        enabled: true

    - name: Open HTTP port in the firewall
      firewalld:
        service: http
        permanent: true
        state: enabled
        immediate: true

    - name: Deploy application homepage
      copy:
        content: "<h1>{{ app_name }} is running</h1>"
        dest: /usr/share/nginx/html/index.html

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

Run it with a dry run first, then for real:

bash
ansible-playbook -i inventory.ini provision.yml --check
ansible-playbook -i inventory.ini provision.yml
curl http://<managed-node-ip>

7. Checkpoint — Can You Answer These?

  1. Why does YAML indentation consistency matter so much in Ansible playbooks?
  2. What's the difference between ok, changed, and failed in playbook output?
  3. What does --check actually do, and why run it before a real deployment?
  4. What's the {{ }} syntax called, and where else besides plain playbooks does it apply?
  5. When would you choose vars_files over inline vars?
  6. In the multi-task project, why does the firewall task run before the nginx start task?

If you can answer all six, you're ready for Phase 3: Variables, Facts & Templates — going much deeper into where variables actually come from, and building real configuration files dynamically.


Quick Reference — This Phase

bash
ansible-playbook -i inventory.ini site.yml
ansible-playbook -i inventory.ini site.yml --check
ansible-playbook -i inventory.ini site.yml --check --diff
ansible-playbook -i inventory.ini site.yml -vvv
yaml
---
- name: <play description>
  hosts: <group>
  become: true
  vars:
    key: value
  vars_files:
    - vars.yml
  tasks:
    - name: <task description>
      <module>:
        <arg>: "{{ variable }}"

Next: Phase 3 — Variables, Facts & Templates. Ask when ready.

Phase 3 of 7

Variables, Facts & Templates

Auto-gathered facts, Jinja2 templates, and variable precedence.

Goal of this phase: Understand where variables actually come from (including auto-discovered system facts), build real configuration files dynamically with Jinja2 templates, and make sense of variable precedence — the part of Ansible that confuses almost everyone at first.

Builds on Phases 1–2.


1. Ansible Facts (Concept + Hands-On)

Before running any task, Ansible automatically gathers detailed information about each managed node — its IP addresses, OS, memory, disk space, hostname, and much more. These are called facts.

bash
# See ALL facts Ansible gathers about a host
ansible web1 -i inventory.ini -m setup

That's a huge amount of output. Filter it:

bash
ansible web1 -i inventory.ini -m setup -a "filter=ansible_distribution*"
ansible web1 -i inventory.ini -m setup -a "filter=ansible_memtotal_mb"

Using facts inside a playbook

yaml
---
- name: Show some facts
  hosts: webservers
  tasks:
    - name: Print the OS and memory
      debug:
        msg: "{{ inventory_hostname }} runs {{ ansible_distribution }} {{ ansible_distribution_version }} with {{ ansible_memtotal_mb }}MB RAM"
bash
ansible-playbook -i inventory.ini facts-demo.yml

Facts are just variables Ansible populates for you automatically — same {{ }} syntax, no different from variables you define yourself.

Disabling fact gathering (performance, when you don't need it)

yaml
- name: A play that doesn't need facts
  hosts: webservers
  gather_facts: false
  tasks:
    - name: ...

On large inventories, skipping fact-gathering for plays that don't use facts noticeably speeds up runs.


2. The debug Module — Your Troubleshooting Best Friend (Hands-On)

yaml
- name: Debug a variable
  debug:
    var: ansible_distribution

- name: Debug a custom message
  debug:
    msg: "The app port is {{ app_port }}"

Whenever a playbook isn't doing what you expect, add a debug task right before the confusing part to print out exactly what value a variable holds at that point. This single habit resolves the majority of "why isn't this working" moments.


3. Registered Variables (Concept + Hands-On)

You can capture the result of a task and use it in later tasks with register.

yaml
---
- name: Check disk usage before deciding what to do
  hosts: webservers
  tasks:
    - name: Get disk usage
      shell: df -h / | tail -1 | awk '{print $5}' | tr -d '%'
      register: disk_usage

    - name: Show what we captured
      debug:
        var: disk_usage.stdout

    - name: Warn if disk usage is high
      debug:
        msg: "WARNING: disk usage is at {{ disk_usage.stdout }}%"
      when: disk_usage.stdout | int > 80

register captures the entire result object (stdout, stderr, return code, etc.), not just a single value — disk_usage.stdout pulls out specifically the command's output. You'll also see when: here as a preview of Phase 4.


4. Jinja2 Templates (Concept + Hands-On)

The copy module (used so far) is fine for static content. The template module processes a file through Jinja2 first, letting you build genuinely dynamic configuration files — this is how real-world Ansible configures things like nginx configs, application config files, and environment files.

Create a template file

Templates conventionally live in a templates/ folder and end in .j2.

templates/nginx.conf.j2:

nginx
server {
    listen {{ app_port }};
    server_name {{ ansible_hostname }};

    location / {
        root {{ web_root }};
        index index.html;
    }
}

Use it in a playbook

yaml
---
- name: Deploy a templated nginx config
  hosts: webservers
  become: true
  vars:
    app_port: 8080
    web_root: /usr/share/nginx/html

  tasks:
    - name: Install nginx
      yum:
        name: nginx
        state: present

    - name: Deploy templated config
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/conf.d/app.conf

    - name: Restart nginx to pick up the new config
      service:
        name: nginx
        state: restarted
        enabled: true

Notice {{ ansible_hostname }} in the template — a fact, used exactly the same way as a variable you defined yourself. This is the payoff of facts and templates working together: a config file automatically tailored to each specific server, generated from one shared template.

Loops and conditionals inside a template

Jinja2 supports logic directly in the template file too:

nginx
{% for port in extra_ports %}
listen {{ port }};
{% endfor %}

{% if enable_ssl %}
listen 443 ssl;
{% endif %}

5. Variable Precedence (Concept — the notoriously confusing part)

Ansible lets you define the "same" variable in many different places — command line, playbook, inventory, role defaults, facts, and more. When a variable is defined in multiple places, Ansible needs rules to decide which value wins. This is genuinely one of the most-referenced pieces of Ansible documentation for a reason — don't feel bad if it takes a couple of passes to stick.

Simplified precedence order (lowest to highest priority — highest wins)

  1. Role defaults/main.yml (Phase 5) — the "fallback" value
  2. Inventory group variables
  3. Inventory host variables
  4. Playbook vars:
  5. Registered variables / facts (set during the run)
  6. extra vars passed on the command line with -ealways wins, no matter what

Hands-On: See Precedence in Action

inventory.ini:

ini
[webservers]
web1 ansible_host=<ip> ansible_user=ec2-user ansible_ssh_private_key_file=~/mykey.pem greeting=from_inventory

precedence-demo.yml:

yaml
---
- name: Precedence demo
  hosts: webservers
  vars:
    greeting: from_playbook

  tasks:
    - name: Show the greeting
      debug:
        var: greeting

Run it three ways and watch the value change:

bash
ansible-playbook -i inventory.ini precedence-demo.yml
# → "from_playbook" (playbook vars beat inventory vars)

ansible-playbook -i inventory.ini precedence-demo.yml -e "greeting=from_cli"
# → "from_cli" (extra vars beat everything)

Practical rule of thumb: if a variable isn't resolving to the value you expect, add a debug task right where it's used, and separately check whether you're passing -e on the command line, since that silently overrides everything else — this single check resolves most precedence confusion in practice.


6. Checkpoint — Can You Answer These?

  1. What are Ansible facts, and how do you view all of them for a host?
  2. What does register do, and how do you access a specific piece of what it captured?
  3. What's the practical difference between the copy module and the template module?
  4. Why might a config file generated from a Jinja2 template differ between two servers in the same group?
  5. In the precedence order, what always wins regardless of anything else, and why is that useful?
  6. What's a fast way to debug "why is this variable not what I expect"?

If you can answer all six, you're ready for Phase 4: Conditionals, Loops & Handlers — making playbooks react intelligently to different situations instead of always doing the exact same thing.


Quick Reference — This Phase

bash
ansible <host> -i inventory.ini -m setup
ansible <host> -i inventory.ini -m setup -a "filter=ansible_distribution*"
ansible-playbook -i inventory.ini playbook.yml -e "var=value"
yaml
- name: Debug
  debug:
    var: some_variable
    # or: msg: "value is {{ some_variable }}"

- name: Capture output
  shell: some-command
  register: result

- name: Use a template
  template:
    src: templates/file.conf.j2
    dest: /etc/file.conf

Next: Phase 4 — Conditionals, Loops & Handlers. Ask when ready.

Phase 4 of 7

Conditionals, Loops & Handlers

when, loop, and restarting services only on real change.

Goal of this phase: Make playbooks react intelligently — run tasks only when needed, repeat tasks over lists of items, and restart services only when something actually changed.

Builds on Phases 1–3.


1. when Conditionals (Concept + Hands-On)

yaml
---
- name: Conditional example
  hosts: webservers
  tasks:
    - name: Install nginx only on RedHat-family systems
      yum:
        name: nginx
        state: present
      when: ansible_os_family == "RedHat"

    - name: Install nginx on Debian-family systems
      apt:
        name: nginx
        state: present
      when: ansible_os_family == "Debian"

This is exactly how you'd write ONE playbook that correctly handles both the yum-based and apt-based systems you've worked with throughout this whole curriculum, instead of maintaining two separate playbooks.

Common condition patterns

yaml
when: ansible_distribution == "Ubuntu"
when: app_port is defined
when: disk_usage.stdout | int > 80
when: environment == "production"
when: inventory_hostname in groups['dbservers']

# Multiple conditions - ALL must be true (AND)
when:
  - ansible_os_family == "RedHat"
  - app_port is defined

# Either condition (OR)
when: ansible_distribution == "Ubuntu" or ansible_distribution == "Debian"

2. Loops (Concept + Hands-On)

Instead of repeating nearly-identical tasks, loop over a list.

yaml
---
- name: Loop example
  hosts: webservers
  become: true
  tasks:
    - name: Install multiple packages
      yum:
        name: "{{ item }}"
        state: present
      loop:
        - nginx
        - htop
        - git
        - curl

Looping over a list of dictionaries

yaml
---
- name: Create multiple users
  hosts: webservers
  become: true
  tasks:
    - name: Create app users
      user:
        name: "{{ item.name }}"
        groups: "{{ item.groups }}"
        state: present
      loop:
        - { name: "deploy", groups: "sudo" }
        - { name: "monitor", groups: "adm" }

The older with_items syntax (you'll see this in legacy playbooks)

yaml
- name: Install packages (older syntax)
  yum:
    name: "{{ item }}"
    state: present
  with_items:
    - nginx
    - htop

loop is the modern, recommended syntax — functionally similar for simple cases, but more consistent and flexible for complex lookups. Recognize with_items when you see it in older playbooks; write new ones with loop.

Combining loop with a variable list

yaml
vars:
  packages:
    - nginx
    - htop
    - git

tasks:
  - name: Install packages from a variable
    yum:
      name: "{{ item }}"
      state: present
    loop: "{{ packages }}"

3. Handlers (Concept + Hands-On)

A handler is a task that only runs when explicitly notified by another task — and crucially, only runs if that task actually reported changed. This is the proper way to restart a service only when its configuration actually changed, instead of restarting it on every single playbook run regardless.

yaml
---
- name: Handler example
  hosts: webservers
  become: true

  tasks:
    - name: Deploy nginx config
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/conf.d/app.conf
      notify: Restart nginx

    - name: Ensure nginx is running
      service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Restart nginx
      service:
        name: nginx
        state: restarted

How this actually behaves: - Run the playbook once → config is deployed (changed) → handler fires → nginx restarts - Run the playbook again, nothing changed → config task reports ok, not changed → handler does NOT fire → nginx is left alone

This is a meaningful improvement over the version you wrote back in Jenkins Phase 6, which always restarted the service outright — handlers make that restart conditional on an actual change, exactly matching Ansible's idempotency philosophy.

Multiple tasks notifying the same handler

yaml
tasks:
  - name: Deploy config file A
    template:
      src: a.conf.j2
      dest: /etc/app/a.conf
    notify: Restart app

  - name: Deploy config file B
    template:
      src: b.conf.j2
      dest: /etc/app/b.conf
    notify: Restart app

handlers:
  - name: Restart app
    service:
      name: app
      state: restarted

Even if BOTH tasks report changed, the handler only runs once, at the end of the play — not once per notification. This avoids unnecessary duplicate restarts.


4. Tags — Running Only Part of a Playbook (Concept + Hands-On)

yaml
---
- name: Tagged tasks example
  hosts: webservers
  become: true
  tasks:
    - name: Install nginx
      yum:
        name: nginx
        state: present
      tags: install

    - name: Deploy config
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/conf.d/app.conf
      tags: config

    - name: Restart nginx
      service:
        name: nginx
        state: restarted
      tags: [config, service]
bash
# Run ONLY tasks tagged "config"
ansible-playbook -i inventory.ini site.yml --tags config

# Run everything EXCEPT tasks tagged "install"
ansible-playbook -i inventory.ini site.yml --skip-tags install

# List all tags in a playbook without running anything
ansible-playbook -i inventory.ini site.yml --list-tags

This is genuinely useful once playbooks grow — e.g., quickly re-pushing just a config change without re-running package installation checks across an entire fleet.


5. Hands-On Project: Combine Everything

yaml
---
- name: Full conditional/loop/handler example
  hosts: webservers
  become: true
  vars:
    packages:
      - nginx
      - htop
    app_port: 8080

  tasks:
    - name: Install packages (RedHat family)
      yum:
        name: "{{ item }}"
        state: present
      loop: "{{ packages }}"
      when: ansible_os_family == "RedHat"
      tags: install

    - name: Deploy nginx config from template
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/conf.d/app.conf
      notify: Restart nginx
      tags: config

    - name: Ensure nginx is enabled and running
      service:
        name: nginx
        state: started
        enabled: true
      tags: service

  handlers:
    - name: Restart nginx
      service:
        name: nginx
        state: restarted

Run it twice in a row and observe: the first run shows changed for the config deployment and the handler fires; the second run shows ok across the board (nothing to change) and the handler does NOT fire — genuine idempotency in action.


6. Checkpoint — Can You Answer These?

  1. Why would one playbook use when: ansible_os_family == "RedHat" instead of writing two separate playbooks?
  2. What's the difference between loop and the older with_items?
  3. What condition has to be true for a handler to actually run?
  4. If two tasks both notify the same handler and both report changed, how many times does the handler run?
  5. What's the practical use case for --tags and --skip-tags?
  6. Why is a handler-based restart considered better practice than always restarting a service unconditionally?

If you can answer all six, you're ready for Phase 5: Roles & Project Structure — organizing playbooks into reusable, shareable components instead of one long file.


Quick Reference — This Phase

yaml
when: some_condition
loop: "{{ some_list }}"

tasks:
  - name: ...
    <module>: ...
    notify: Handler Name

handlers:
  - name: Handler Name
    <module>: ...
bash
ansible-playbook -i inventory.ini site.yml --tags config
ansible-playbook -i inventory.ini site.yml --skip-tags install
ansible-playbook -i inventory.ini site.yml --list-tags

Next: Phase 5 — Roles & Project Structure. Ask when ready.

Phase 5 of 7

Roles & Project Structure

Reusable roles, Ansible Galaxy, and refactoring a monolith.

Goal of this phase: Stop writing single monolithic playbooks — organize your automation into reusable, shareable roles, the standard structure real Ansible projects use.

Builds on Phases 1–4.


1. Why Roles? (Concept)

By now your playbooks are growing — tasks, handlers, templates, variables, all in one or two files. Roles solve this by giving each logical piece of configuration (e.g., "set up nginx," "set up a database," "harden SSH") its own self-contained, standardized folder structure — reusable across many playbooks and even shareable with other people/projects entirely.

This is the same organizational shift as Jenkins Freestyle jobs → Pipelines, or a single Docker container → Compose: moving from "everything in one place, order-dependent" to "defined, reusable, composable units."


2. Standard Role Directory Structure (Concept)

shell
roles/
└── nginx/
    ├── tasks/
    │   └── main.yml         # the actual tasks (like what you'd put directly in a playbook)
    ├── handlers/
    │   └── main.yml         # handlers, scoped to this role
    ├── templates/
    │   └── nginx.conf.j2    # Jinja2 templates used by this role
    ├── files/
    │   └── static-file.txt  # static files to copy as-is
    ├── vars/
    │   └── main.yml         # variables that are NOT meant to be overridden easily (high precedence)
    ├── defaults/
    │   └── main.yml         # default variable values (LOW precedence - meant to be overridden)
    └── meta/
        └── main.yml         # role metadata, dependencies on other roles

You don't need every folder for every role — only include what you actually use. tasks/main.yml is the only one that's essentially always present.

vars/ vs defaults/ — precedence in practice (ties back to Phase 3)

  • defaults/main.yml — the lowest precedence in the entire precedence chain from Phase 3. Meant to be easily overridden by playbook vars, inventory vars, or -e.
  • vars/main.yml — much higher precedence, harder to override. Use for values that genuinely shouldn't change per-environment.

Practical rule: put anything a user of this role might reasonably want to customize (a port number, a package version) in defaults/. Put internal implementation details the role depends on (an internal file path it always uses) in vars/.


3. Hands-On: Create Your First Role

bash
mkdir -p my-ansible-project/roles
cd my-ansible-project

ansible-galaxy init roles/nginx

This scaffolds the entire standard directory structure automatically. Explore what it created:

bash
find roles/nginx -type f

Fill in the role

roles/nginx/defaults/main.yml:

yaml
---
nginx_port: 8080
web_root: /usr/share/nginx/html

roles/nginx/tasks/main.yml:

yaml
---
- name: Install nginx
  yum:
    name: nginx
    state: present

- name: Deploy nginx config from template
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/conf.d/app.conf
  notify: Restart nginx

- name: Ensure nginx is running and enabled
  service:
    name: nginx
    state: started
    enabled: true

roles/nginx/handlers/main.yml:

yaml
---
- name: Restart nginx
  service:
    name: nginx
    state: restarted

roles/nginx/templates/nginx.conf.j2:

nginx
server {
    listen {{ nginx_port }};
    root {{ web_root }};
    index index.html;
}

Notice: inside a role, template paths are relative to the role's own templates/ folder — you just write nginx.conf.j2, not the full path, since Ansible knows to look inside the role.

Use the role from a playbook

site.yml (at the project root, alongside the roles/ folder):

yaml
---
- name: Configure web servers using the nginx role
  hosts: webservers
  become: true
  roles:
    - nginx

That's the entire playbook now — roles: replaces tasks: at the play level, and Ansible automatically finds and runs everything inside roles/nginx/.

bash
ansible-playbook -i inventory.ini site.yml

Overriding a role's defaults

yaml
---
- name: Configure web servers with a custom port
  hosts: webservers
  become: true
  roles:
    - role: nginx
      vars:
        nginx_port: 9090

Because nginx_port lives in defaults/, this override works cleanly — exactly the precedence behavior from Phase 3, now put to practical use.


4. Multiple Roles in One Playbook (Concept + Hands-On)

Real projects almost always combine several roles.

bash
ansible-galaxy init roles/common
ansible-galaxy init roles/firewall

roles/common/tasks/main.yml:

yaml
---
- name: Install common packages
  yum:
    name:
      - htop
      - vim
      - curl
    state: present

site.yml:

yaml
---
- name: Full server setup
  hosts: webservers
  become: true
  roles:
    - common
    - firewall
    - nginx

Roles run in the order listed — here, common tools first, then firewall rules, then the actual web server. This ordering often matters (e.g., you generally want the firewall configured before or alongside the service that needs a port open).


5. Using Community Roles from Ansible Galaxy (Concept + Hands-On)

Ansible Galaxy is a public hub of pre-built, community-maintained roles — similar in spirit to Docker Hub for images, or npm for JavaScript packages.

bash
# Search Galaxy from the command line
ansible-galaxy search nginx

# Install a community role
ansible-galaxy install geerlingguy.nginx

# Roles installed this way land in ~/.ansible/roles/ by default
ansible-galaxy list

Use it in a playbook exactly like your own role:

yaml
---
- name: Use a community role
  hosts: webservers
  become: true
  roles:
    - geerlingguy.nginx

requirements.yml — declaring dependencies for a project

yaml
---
roles:
  - name: geerlingguy.nginx
    version: "3.1.0"
  - name: geerlingguy.docker
bash
ansible-galaxy install -r requirements.yml

This is the Ansible equivalent of a requirements.txt (Python) or package.json (Node) — a declared, version-pinned list of dependencies, so anyone else running your project gets exactly the same roles installed.

Practical guidance: writing your own roles teaches you the structure and gives full control; community roles from Galaxy save significant time for common, well-solved problems (Docker installation, common hardening baselines, etc.) — real projects usually mix both.


6. Hands-On Project: Refactor a Monolithic Playbook into Roles

Take the combined playbook from Phase 4 (packages + template + service, all in one file) and refactor it:

bash
ansible-galaxy init roles/webserver

Move the tasks, handler, and template into roles/webserver/, following the pattern from section 3, then reduce your top-level playbook to:

yaml
---
- name: Provision web servers
  hosts: webservers
  become: true
  roles:
    - webserver

Run it and confirm the behavior is identical to the Phase 4 version — same result, dramatically more maintainable and reusable structure.


7. Checkpoint — Can You Answer These?

  1. What problem do roles solve that a single large playbook doesn't?
  2. What's the practical difference between defaults/main.yml and vars/main.yml?
  3. What does ansible-galaxy init actually generate?
  4. Why don't you need to write the full path when referencing a template from inside a role's tasks?
  5. What determines the order roles execute in, when a playbook lists several?
  6. What problem does requirements.yml solve for a team sharing an Ansible project?

If you can answer all six, you're ready for Phase 6: Ansible Vault & Security — safely handling secrets (passwords, API keys, certificates) inside your roles and playbooks.


Quick Reference — This Phase

bash
ansible-galaxy init roles/<name>
ansible-galaxy search <keyword>
ansible-galaxy install <role-name>
ansible-galaxy install -r requirements.yml
ansible-galaxy list
shell
roles/<name>/
├── tasks/main.yml
├── handlers/main.yml
├── templates/
├── defaults/main.yml     ← low precedence, meant to be overridden
└── vars/main.yml         ← high precedence
yaml
- hosts: webservers
  roles:
    - common
    - role: nginx
      vars:
        nginx_port: 9090

Next: Phase 6 — Ansible Vault & Security. Ask when ready.

Phase 6 of 7

Ansible Vault & Security

Encrypting secrets and integrating them into roles.

Goal of this phase: Stop putting plaintext passwords, API keys, and certificates in your playbooks — encrypt them properly with Ansible Vault, and integrate encrypted values cleanly into roles.

Builds on Phases 1–5. Directly parallels the credentials-management patterns you already know from Jenkins Phase 3 and Docker Phase 5 — same underlying problem, Ansible's own solution.


1. Why Vault? (Concept)

Playbooks often need secrets: a database password, an API token, a TLS private key. Committing these in plaintext to a Git repository (which most Ansible projects live in) is a serious security problem — anyone with repo access, or access to its history, has the secret forever.

Ansible Vault encrypts any file (or just specific values) so it's safe to commit to version control — only someone with the vault password can decrypt and use it.


2. Encrypting a Whole File (Concept + Hands-On)

bash
# Create and encrypt a new file in one step
ansible-vault create secrets.yml

This opens your default editor. Type your secret content:

yaml
db_password: SuperSecret123
api_key: abc123xyz789

Save and exit — the file on disk is now encrypted, unreadable without the vault password.

bash
cat secrets.yml
# $ANSIBLE_VAULT;1.1;AES256
# 66386439653236336462626566653063336164663966303231363934653561...

Viewing and editing an encrypted file

bash
ansible-vault view secrets.yml           # decrypt and display, without saving decrypted content anywhere
ansible-vault edit secrets.yml              # decrypt, open in your editor, re-encrypt on save

Encrypting an EXISTING plaintext file

bash
ansible-vault encrypt existing-vars.yml

Decrypting permanently (rarely what you want, but useful to know)

bash
ansible-vault decrypt secrets.yml

Changing the vault password

bash
ansible-vault rekey secrets.yml

3. Using an Encrypted File in a Playbook (Concept + Hands-On)

yaml
---
- name: Use vault-encrypted variables
  hosts: webservers
  vars_files:
    - secrets.yml

  tasks:
    - name: Show we have the password (for demo only - never do this with a real secret!)
      debug:
        msg: "DB password starts with: {{ db_password[:3] }}"

Running this playbook requires providing the vault password, since Ansible needs to decrypt secrets.yml at runtime:

bash
# Prompt for the password interactively
ansible-playbook -i inventory.ini site.yml --ask-vault-pass

# Or supply it via a password file (see section 4 - useful for automation/CI)
ansible-playbook -i inventory.ini site.yml --vault-password-file ~/.vault_pass.txt

Without one of these flags, Ansible will simply refuse to run, since it can't decrypt the required file.


4. Vault Password Files (Concept + Hands-On) — Essential for CI/CD

Typing a password interactively works fine locally, but breaks down entirely for automation — this is exactly the scenario your Jenkins pipeline (Jenkins Phase 6) needs to handle.

bash
# Create a password file (just the password, one line, nothing else)
echo "MyVaultPassword123" > ~/.vault_pass.txt
chmod 600 ~/.vault_pass.txt    # exactly the private-key permission pattern from Linux Phase 2
bash
ansible-playbook -i inventory.ini site.yml --vault-password-file ~/.vault_pass.txt

Critical: never commit the vault password file itself to version control — add it to .gitignore. The whole point of Vault is that the encrypted secrets are safe to commit, but the password that unlocks them absolutely is not.

Using an ansible.cfg default instead of typing the flag every time

ini
[defaults]
vault_password_file = ~/.vault_pass.txt

With this set, plain ansible-playbook -i inventory.ini site.yml works without any extra flags — Ansible automatically knows where to find the password.


5. Encrypting a Single Variable (Concept + Hands-On)

Sometimes you don't want to encrypt an entire file — just one sensitive value inside an otherwise-plaintext, readable file.

bash
ansible-vault encrypt_string 'SuperSecret123' --name 'db_password'

This outputs something like:

yaml
db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          66386439653236336462626566653063336164663966303231363934653561...

Paste that directly into an otherwise-normal, plaintext vars.yml file — everything else in the file stays readable, and only that one value is encrypted:

yaml
# vars.yml - safe to read/diff normally, except the one encrypted value
app_name: my-app
app_port: 8080
db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          66386439653236336462626566653063336164663966303231363934653561...

This is genuinely the more common real-world pattern — most of a vars file is perfectly fine to read in a code review; only the truly sensitive lines need to stay opaque.


6. Integrating Vault with Roles (Concept + Hands-On)

The natural home for encrypted values in a role is vars/main.yml (or a dedicated vault.yml alongside it).

shell
roles/webserver/
├── vars/
│   └── main.yml       # plaintext, references vault-encrypted values
├── vars/
│   └── vault.yml       # fully encrypted file (ansible-vault encrypt)

roles/webserver/vars/vault.yml (encrypted with ansible-vault encrypt):

yaml
vault_db_password: SuperSecret123

roles/webserver/vars/main.yml (plaintext, readable):

yaml
db_password: "{{ vault_db_password }}"

Why the indirection? This pattern — a plaintext variable name referencing a vault_-prefixed encrypted one — is a widely-used Ansible community convention. It keeps your task files readable ({{ db_password }} reads clearly) while isolating literally everything sensitive into one small, fully-encrypted file. vars/main.yml is automatically loaded by the role, no extra vars_files: needed.


7. Hands-On Project: Vault-Protected Database Credentials

bash
mkdir -p roles/database/vars
ansible-vault create roles/database/vars/vault.yml

Content:

yaml
vault_db_root_password: RootPass123!
vault_db_app_password: AppPass456!

roles/database/vars/main.yml (plaintext):

yaml
db_root_password: "{{ vault_db_root_password }}"
db_app_password: "{{ vault_db_app_password }}"

roles/database/tasks/main.yml:

yaml
---
- name: Show that we can reference the vault-backed variable (demo only)
  debug:
    msg: "DB app password is configured (length: {{ db_app_password | length }})"

site.yml:

yaml
---
- name: Configure database
  hosts: dbservers
  become: true
  roles:
    - database
bash
ansible-playbook -i inventory.ini site.yml --vault-password-file ~/.vault_pass.txt

8. Checkpoint — Can You Answer These?

  1. What problem does Ansible Vault solve, and why is it especially important for playbooks stored in Git?
  2. What's the difference between ansible-vault edit and ansible-vault view?
  3. Why is a vault password file essential for CI/CD automation, and why must it never itself be committed to Git?
  4. What does encrypt_string let you do that whole-file encryption doesn't?
  5. In the vault_db_passworddb_password indirection pattern, what's the actual benefit of that extra layer?
  6. What real-world credential-handling pattern from your Jenkins work does this whole phase closely mirror?

If you can answer all six, you're ready for Phase 7: Production Practices & Integration — the final phase, covering dynamic inventory, performance tuning, and tying Ansible fully back into the Terraform → Ansible → Jenkins pipeline you've been building across this whole curriculum.


Quick Reference — This Phase

bash
ansible-vault create secrets.yml
ansible-vault edit secrets.yml
ansible-vault view secrets.yml
ansible-vault encrypt existing-file.yml
ansible-vault decrypt secrets.yml
ansible-vault rekey secrets.yml
ansible-vault encrypt_string 'secret' --name 'var_name'

ansible-playbook -i inventory.ini site.yml --ask-vault-pass
ansible-playbook -i inventory.ini site.yml --vault-password-file ~/.vault_pass.txt
ini
# ansible.cfg
[defaults]
vault_password_file = ~/.vault_pass.txt

Next: Phase 7 — Production Practices & Integration. Ask when ready.

Phase 7 of 7

Production Practices

Dynamic AWS inventory, performance tuning, full pipeline integration.

Goal of this phase: Move from static inventory files to dynamic, cloud-aware inventory, tune Ansible for real-world performance and reliability, and close the loop on the full Terraform → Ansible → Jenkins pipeline you've been building across this entire curriculum.

This is the final phase in the core Ansible curriculum. Builds on everything in Phases 1–6.


1. Dynamic Inventory (Concept + Hands-On)

Every static inventory.ini you've written so far has a real weakness: it needs manual updates every time a server is added or removed. In a cloud environment where Terraform creates and destroys EC2 instances routinely, that's a losing battle. Dynamic inventory queries AWS directly, in real time, for the current list of matching instances.

Install the AWS collection and dependencies

bash
ansible-galaxy collection install amazon.aws
pip install boto3 botocore --break-system-packages

Configure the AWS EC2 dynamic inventory plugin

inventory.aws_ec2.yml:

yaml
plugin: amazon.aws.aws_ec2
regions:
  - us-east-1
filters:
  tag:Environment: production
  instance-state-name: running
keyed_groups:
  - key: tags.Role
    prefix: role
hostnames:
  - public-ip-address

This configuration says: find all running EC2 instances tagged Environment: production in us-east-1, group them automatically by their Role tag, and use each instance's public IP as its address.

bash
# List what the dynamic inventory discovers
ansible-inventory -i inventory.aws_ec2.yml --graph

# Use it exactly like a static inventory file
ansible all -i inventory.aws_ec2.yml -m ping
ansible-playbook -i inventory.aws_ec2.yml site.yml

The real payoff: if your Terraform code (from Jenkins Phase 6) tags every EC2 instance it creates with Environment: production and a Role tag, Ansible automatically discovers and correctly groups every new server the moment Terraform provisions it — zero manual inventory editing, ever.


2. ansible.cfg — Project-Level Configuration (Concept + Hands-On)

Instead of passing the same flags on every command, a project-local ansible.cfg sets defaults for anyone working in that directory.

ini
[defaults]
inventory = inventory.ini
remote_user = ec2-user
private_key_file = ~/mykey.pem
host_key_checking = False
vault_password_file = ~/.vault_pass.txt
retry_files_enabled = False

[privilege_escalation]
become = True
become_method = sudo

With this file in your project root, a bare ansible-playbook site.yml picks up all of these defaults automatically — no more retyping -i inventory.ini --vault-password-file ... on every single command.

host_key_checking = False — disables SSH's interactive "are you sure you want to continue connecting?" prompt for unknown hosts. Convenient for fast-changing cloud environments where instances get new IPs constantly, but be aware it trades away a security check — reasonable for internal automation against known infrastructure, less so for anything internet-facing you don't fully control.


3. Performance Tuning (Concept + Hands-On)

Forks — how many hosts Ansible works on simultaneously

By default, Ansible runs tasks against only 5 hosts at once, then moves to the next batch. On larger inventories, this becomes a real bottleneck.

ini
[defaults]
forks = 20

Or per-run:

bash
ansible-playbook -i inventory.ini site.yml --forks 20

Pipelining — reduce SSH round-trips

ini
[ssh_connection]
pipelining = True

Normally Ansible transfers a small module script to the remote host, then executes it as a separate step — two round trips per task. Pipelining combines these, meaningfully speeding up playbook runs, especially over higher-latency connections.

Fact caching — skip re-gathering facts on every run

ini
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 3600

gathering = smart skips fact-gathering entirely if cached facts are still fresh (within fact_caching_timeout seconds) — valuable when running many playbooks back-to-back against the same fleet.


4. Idempotency Testing (Concept + Hands-On)

Idempotency — running the same playbook twice produces the same end state, with the second run reporting no changes — is one of Ansible's core promises. It's worth deliberately testing, not just assuming.

bash
# Run once
ansible-playbook -i inventory.ini site.yml

# Run again immediately - a well-written playbook should now report ZERO "changed" tasks
ansible-playbook -i inventory.ini site.yml

If the second run still shows changed tasks, that's a sign something in the playbook isn't truly idempotent — a common culprit is using the shell or command modules for something a proper Ansible module already handles natively (those raw commands run every time, with no built-in "check first" behavior). Prefer dedicated modules (yum, copy, template, service) over shell/command whenever one exists, specifically because they check current state before acting.


5. Error Handling (Concept + Hands-On)

ignore_errors — continue past a failed task

yaml
- name: This might fail, but don't stop the whole play
  command: /some/risky/script.sh
  ignore_errors: true

Use sparingly — silently ignoring failures can hide real problems. Reasonable for genuinely optional steps; risky as a blanket habit.

block / rescue / always — structured error handling

yaml
tasks:
  - block:
      - name: Attempt to deploy the new version
        copy:
          src: app.jar
          dest: /opt/app/app.jar

      - name: Restart the application
        service:
          name: myapp
          state: restarted

    rescue:
      - name: Deployment failed - roll back
        copy:
          src: app.jar.backup
          dest: /opt/app/app.jar

      - name: Restart with the rolled-back version
        service:
          name: myapp
          state: restarted

    always:
      - name: Always log that a deploy was attempted
        lineinfile:
          path: /var/log/deploy-attempts.log
          line: "Deploy attempted at {{ ansible_date_time.iso8601 }}"
          create: true

This mirrors try/rescue/finally error handling from general programming, and mirrors the manual-approval-gate spirit of the Jenkins pipelines you built in Jenkins Phase 4 — a real safety pattern for anything deploy-related, not just a syntax curiosity.


6. Full-Circle Integration: Terraform → Ansible → Jenkins

This is the natural closing point for this entire curriculum — connecting back to the pipeline you built in Jenkins Phase 6.

Recall that pipeline's stages: Terraform provisions an EC2 instance and outputs its IP → that IP is handed to Ansible → Ansible configures the server and deploys a Docker container. Here's how everything from this Ansible curriculum upgrades that pipeline into something genuinely production-grade:

Jenkins Phase 6's version Upgraded with what you now know
Static inventory built from a Terraform output variable Dynamic AWS EC2 inventory, tag-based, no manual inventory maintenance (section 1)
A single flat playbook Organized into roles: common, docker, app-deploy (Phase 5)
Plaintext AWS/app credentials in the pipeline Vault-encrypted secrets, decrypted via a vault password file Jenkins provides as a credential (Phase 6 + Jenkins Phase 3's credentials store)
Unconditional service restart Handler-based restart, only on actual config change (Phase 4)
No handling for a failed deploy block/rescue to roll back automatically on failure (section 5)

An updated Jenkinsfile stage reflecting this:

groovy
stage('Ansible Configure & Deploy') {
    steps {
        dir('ansible') {
            withCredentials([file(credentialsId: 'ansible-vault-password', variable: 'VAULT_PASS_FILE')]) {
                sh """
                    ansible-playbook -i inventory.aws_ec2.yml site.yml \
                      --vault-password-file \$VAULT_PASS_FILE \
                      --extra-vars "docker_image=${IMAGE_NAME}:${IMAGE_TAG}"
                """
            }
        }
    }
}

This single stage now represents everything across all four curriculums you've built: Linux fundamentals underneath, Docker packaging the app, Terraform-provisioned infrastructure discovered dynamically, and a role-based, vault-secured, idempotent Ansible deployment — orchestrated by Jenkins.


7. Checkpoint — Can You Answer These?

  1. What problem does dynamic inventory solve that a static inventory.ini can't, in a cloud environment?
  2. What does ansible.cfg let you avoid repeating on every command?
  3. What does increasing forks actually change about how a playbook runs?
  4. How would you deliberately test whether a playbook is truly idempotent?
  5. What's the difference between ignore_errors and a block/rescue structure?
  6. In the updated Jenkins pipeline stage, where does the vault password actually come from, and why is that safer than hardcoding it?

If you can answer all six, you've completed the full 7-phase Ansible curriculum — from zero, to playbooks, to variables and templates, to roles, to vault-protected secrets, to a genuinely production-grade, dynamically-inventoried deployment pipeline.


Quick Reference — This Phase

bash
ansible-galaxy collection install amazon.aws
ansible-inventory -i inventory.aws_ec2.yml --graph
ansible-playbook -i inventory.aws_ec2.yml site.yml --forks 20
ini
# ansible.cfg
[defaults]
inventory = inventory.ini
forks = 20
gathering = smart
fact_caching = jsonfile

[ssh_connection]
pipelining = True
yaml
tasks:
  - block:
      - name: ...
    rescue:
      - name: ...
    always:
      - name: ...

What's Next (Beyond This Curriculum)

  • Ansible Tower / AWX — a web UI, RBAC, and scheduling layer on top of Ansible, for teams that outgrow command-line-only workflows
  • Molecule — a testing framework specifically for Ansible roles, for verifying roles work correctly before they hit production
  • The full four-curriculum capstone: Linux + Docker + Terraform/AWS (Jenkins Phase 6) + this Ansible curriculum, all orchestrated by the Jenkins pipeline — genuinely comprehensive, resume-ready DevOps engineering, start to finish

Congratulations on finishing the Ansible curriculum — combined with Linux, Docker, and Jenkins, you now have a complete, production-relevant automation and CI/CD skill set spanning the entire stack.

You've reached the end