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
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.
┌─────────────────┐ 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
# 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.
# 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:
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:
[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 — targetingproductionhits bothwebserversanddbservers- 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:
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
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.
# 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
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):
# 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?
- What does "agentless" mean, and why does that make Ansible's prerequisites simpler than some alternatives?
- What's the difference between a control node and a managed node?
- In an inventory file, what does
[production:children]do? - What does
--becomedo, and when do you need it? - What does
"changed": falsein ad-hoc command output actually tell you? - 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
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
[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.
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)
---
- 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:
---
- 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:
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
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
ansible-playbook -i inventory.ini site.yml --check --diff
Verbosity levels — debugging when something isn't behaving as expected
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
---
- 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:
web_package: nginx
web_root: /usr/share/nginx/html
Reference it from the playbook:
---
- 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:
---
- 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:
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?
- Why does YAML indentation consistency matter so much in Ansible playbooks?
- What's the difference between
ok,changed, andfailedin playbook output? - What does
--checkactually do, and why run it before a real deployment? - What's the
{{ }}syntax called, and where else besides plain playbooks does it apply? - When would you choose
vars_filesover inlinevars? - 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
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
---
- 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.
# See ALL facts Ansible gathers about a host
ansible web1 -i inventory.ini -m setup
That's a huge amount of output. Filter it:
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
---
- 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"
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)
- 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)
- 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.
---
- 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:
server {
listen {{ app_port }};
server_name {{ ansible_hostname }};
location / {
root {{ web_root }};
index index.html;
}
}
Use it in a playbook
---
- 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:
{% 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)
- Role
defaults/main.yml(Phase 5) — the "fallback" value - Inventory group variables
- Inventory host variables
- Playbook
vars: - Registered variables / facts (set during the run)
extra varspassed on the command line with-e— always wins, no matter what
Hands-On: See Precedence in Action
inventory.ini:
[webservers]
web1 ansible_host=<ip> ansible_user=ec2-user ansible_ssh_private_key_file=~/mykey.pem greeting=from_inventory
precedence-demo.yml:
---
- 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:
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?
- What are Ansible facts, and how do you view all of them for a host?
- What does
registerdo, and how do you access a specific piece of what it captured? - What's the practical difference between the
copymodule and thetemplatemodule? - Why might a config file generated from a Jinja2 template differ between two servers in the same group?
- In the precedence order, what always wins regardless of anything else, and why is that useful?
- 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
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"
- 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)
---
- 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
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.
---
- 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
---
- 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)
- 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
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.
---
- 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
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)
---
- 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]
# 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
---
- 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?
- Why would one playbook use
when: ansible_os_family == "RedHat"instead of writing two separate playbooks? - What's the difference between
loopand the olderwith_items? - What condition has to be true for a handler to actually run?
- If two tasks both
notifythe same handler and both reportchanged, how many times does the handler run? - What's the practical use case for
--tagsand--skip-tags? - 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
when: some_condition
loop: "{{ some_list }}"
tasks:
- name: ...
<module>: ...
notify: Handler Name
handlers:
- name: Handler Name
<module>: ...
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)
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
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:
find roles/nginx -type f
Fill in the role
roles/nginx/defaults/main.yml:
---
nginx_port: 8080
web_root: /usr/share/nginx/html
roles/nginx/tasks/main.yml:
---
- 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:
---
- name: Restart nginx
service:
name: nginx
state: restarted
roles/nginx/templates/nginx.conf.j2:
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):
---
- 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/.
ansible-playbook -i inventory.ini site.yml
Overriding a role's defaults
---
- 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.
ansible-galaxy init roles/common
ansible-galaxy init roles/firewall
roles/common/tasks/main.yml:
---
- name: Install common packages
yum:
name:
- htop
- vim
- curl
state: present
site.yml:
---
- 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.
# 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:
---
- name: Use a community role
hosts: webservers
become: true
roles:
- geerlingguy.nginx
requirements.yml — declaring dependencies for a project
---
roles:
- name: geerlingguy.nginx
version: "3.1.0"
- name: geerlingguy.docker
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:
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:
---
- 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?
- What problem do roles solve that a single large playbook doesn't?
- What's the practical difference between
defaults/main.ymlandvars/main.yml? - What does
ansible-galaxy initactually generate? - Why don't you need to write the full path when referencing a template from inside a role's tasks?
- What determines the order roles execute in, when a playbook lists several?
- What problem does
requirements.ymlsolve 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
ansible-galaxy init roles/<name>
ansible-galaxy search <keyword>
ansible-galaxy install <role-name>
ansible-galaxy install -r requirements.yml
ansible-galaxy list
roles/<name>/
├── tasks/main.yml
├── handlers/main.yml
├── templates/
├── defaults/main.yml ← low precedence, meant to be overridden
└── vars/main.yml ← high precedence
- 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)
# Create and encrypt a new file in one step
ansible-vault create secrets.yml
This opens your default editor. Type your secret content:
db_password: SuperSecret123
api_key: abc123xyz789
Save and exit — the file on disk is now encrypted, unreadable without the vault password.
cat secrets.yml
# $ANSIBLE_VAULT;1.1;AES256
# 66386439653236336462626566653063336164663966303231363934653561...
Viewing and editing an encrypted file
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
ansible-vault encrypt existing-vars.yml
Decrypting permanently (rarely what you want, but useful to know)
ansible-vault decrypt secrets.yml
Changing the vault password
ansible-vault rekey secrets.yml
3. Using an Encrypted File in a Playbook (Concept + Hands-On)
---
- 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:
# 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.
# 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
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
[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.
ansible-vault encrypt_string 'SuperSecret123' --name 'db_password'
This outputs something like:
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:
# 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).
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):
vault_db_password: SuperSecret123
roles/webserver/vars/main.yml (plaintext, readable):
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
mkdir -p roles/database/vars
ansible-vault create roles/database/vars/vault.yml
Content:
vault_db_root_password: RootPass123!
vault_db_app_password: AppPass456!
roles/database/vars/main.yml (plaintext):
db_root_password: "{{ vault_db_root_password }}"
db_app_password: "{{ vault_db_app_password }}"
roles/database/tasks/main.yml:
---
- 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:
---
- name: Configure database
hosts: dbservers
become: true
roles:
- database
ansible-playbook -i inventory.ini site.yml --vault-password-file ~/.vault_pass.txt
8. Checkpoint — Can You Answer These?
- What problem does Ansible Vault solve, and why is it especially important for playbooks stored in Git?
- What's the difference between
ansible-vault editandansible-vault view? - Why is a vault password file essential for CI/CD automation, and why must it never itself be committed to Git?
- What does
encrypt_stringlet you do that whole-file encryption doesn't? - In the
vault_db_password→db_passwordindirection pattern, what's the actual benefit of that extra layer? - 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
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
# 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
ansible-galaxy collection install amazon.aws
pip install boto3 botocore --break-system-packages
Configure the AWS EC2 dynamic inventory plugin
inventory.aws_ec2.yml:
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.
# 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.
[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.
[defaults]
forks = 20
Or per-run:
ansible-playbook -i inventory.ini site.yml --forks 20
Pipelining — reduce SSH round-trips
[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
[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.
# 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
- 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
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:
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?
- What problem does dynamic inventory solve that a static
inventory.inican't, in a cloud environment? - What does
ansible.cfglet you avoid repeating on every command? - What does increasing
forksactually change about how a playbook runs? - How would you deliberately test whether a playbook is truly idempotent?
- What's the difference between
ignore_errorsand ablock/rescuestructure? - 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
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
# ansible.cfg
[defaults]
inventory = inventory.ini
forks = 20
gathering = smart
fact_caching = jsonfile
[ssh_connection]
pipelining = True
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.