What We're Building
A 4-task playbook that, on a fresh OEL 8 host, will: install httpd, drop a custom
index.html, configure the firewall, and start the service. After running it once,
running it again should show zero changes — that's idempotency in action.
Step 1 — Lay Out the Project
BASH — project layout
# create the directory structure
mkdir -p first-playbook/{templates,files}
cd first-playbook
# inventory file
cat > hosts.ini <<EOF
[oel8]
192.168.56.11
[oel8:vars]
ansible_user=deploy
EOF
# minimal ansible.cfg so we don't have to pass -i every time
cat > ansible.cfg <<EOF
[defaults]
inventory = ./hosts.ini
host_key_checking = False
stdout_callback = yaml
[privilege_escalation]
become = True
EOF
ls
# ansible.cfg files hosts.ini templates
Step 2 — Write site.yml
YAML — site.yml
---
- name: Configure a basic Apache web server on OEL 8
hosts: oel8
become: true
vars:
httpd_port: 80
welcome_message: "Hello from Ansible on {{{{ inventory_hostname }}}}"
tasks:
- name: Install Apache HTTPD
ansible.builtin.dnf:
name: httpd
state: present
- name: Render the welcome page from template
ansible.builtin.copy:
dest: /var/www/html/index.html
content: |
<html>
<head><title>It works</title></head>
<body>
<h1>{{{{ welcome_message }}}}</h1>
<p>This page was deployed by Ansible at {{{{ ansible_date_time.iso8601 }}}}.</p>
</body>
</html>
owner: apache
group: apache
mode: "0644"
- name: Open the HTTP port in firewalld
ansible.posix.firewalld:
service: http
permanent: true
state: enabled
immediate: true
- name: Start and enable httpd at boot
ansible.builtin.systemd:
name: httpd
state: started
enabled: true
💡 Note: The double braces inside the template (
{{{{ welcome_message }}}}) are Jinja2 placeholders. Ansible swaps them with variable values just before executing the task. We cover Jinja2 properly on page 13 of this series. Step 3 — Syntax-Check First, Always
BASH — syntax check
ansible-playbook --syntax-check site.yml
# expected output:
# playbook: site.yml
Step 4 — Dry Run
BASH — dry run
ansible-playbook site.yml --check --diff
# --check : simulate, don't actually change anything
# --diff : show the line-level diff for any file Ansible would change
Read the output carefully. Every task should be reported as either ok (already
in the desired state) or changed (would be modified). If anything says failed
or unreachable, fix it before running for real.
Step 5 — Run It For Real
BASH — first run
ansible-playbook site.yml
# typical output (truncated, real time ~ 8s)
PLAY [Configure a basic Apache web server on OEL 8] **********
TASK [Gathering Facts] ***************************************
ok: [192.168.56.11]
TASK [Install Apache HTTPD] **********************************
changed: [192.168.56.11]
TASK [Render the welcome page from template] *****************
changed: [192.168.56.11]
TASK [Open the HTTP port in firewalld] ***********************
changed: [192.168.56.11]
TASK [Start and enable httpd at boot] ************************
changed: [192.168.56.11]
PLAY RECAP ***************************************************
192.168.56.11 : ok=5 changed=4 unreachable=0 failed=0 skipped=0
Step 6 — Verify From Outside
BASH — verify it works
curl -s http://192.168.56.11/
# <html>
# <head><title>It works</title></head>
# <body>
# <h1>Hello from Ansible on db1.example.com</h1>
# <p>This page was deployed by Ansible at 2026-04-30T08:42:11Z.</p>
# </body>
# </html>
Step 7 — Run It Again to See Idempotency
BASH — second run shows zero changes
ansible-playbook site.yml
PLAY RECAP ***************************************************
192.168.56.11 : ok=5 changed=0 unreachable=0 failed=0 skipped=0
# ^^^^^^^^^
# every task: ok, no change made
✅ Tip: That zero-changes second run is the whole point of declarative automation. You can run this playbook every 5 minutes via cron or in a CI pipeline and nothing breaks — Ansible only acts when reality drifts from the desired state.
What You Just Learned
- The 4-step develop loop:
--syntax-check→--check --diff→ run → re-run to verify idempotency - How variables (
vars:) are referenced in tasks via Jinja2 - The difference between
changed(something modified) andok(already correct) - That a playbook + inventory + ansible.cfg is the minimum viable Ansible project