The Nautilus DevOps team is practicing some of the Ansible modules and creating and testing different Ansible playbooks to accomplish tasks. Recently they started testing an Ansible file module to create soft links on all app servers. Below you can find more details about it.
Write a playbook.yml under /home/thor/ansible directory on jump host, an inventory file is already present under /home/thor/ansible directory on jump host itself. Using this playbook accomplish below given tasks:
Create an empty file
/opt/dba/blog.txton app server 1; its user owner and group owner should betony. Create asymbolic linkof source path/opt/dbato destination/var/www/html.Create an empty file
/opt/dba/story.txton app server 2; its user owner and group owner should besteve. Create asymbolic linkof source path/opt/dbato destination/var/www/html.Create an empty file
/opt/dba/media.txton app server 3; its user owner and group owner should bebanner. Create asymbolic linkof source path/opt/dbato destination/var/www/html.
Note: Validation will try to run the playbook using command ansible-playbook -i inventory playbook.yml so please make sure playbook works this way without passing any extra arguments.
Creating Soft Links with Ansible: A Complete Walkthrough
From Requirement to Verified Success
🎯 The Requirement
Create the following across three app servers:
| Server | File | Owner:Group | Symlink |
|---|---|---|---|
| stapp01 | /opt/dba/blog.txt |
tony:tony |
/opt/dba → /var/www/html
|
| stapp02 | /opt/dba/story.txt |
steve:steve |
/opt/dba → /var/www/html
|
| stapp03 | /opt/dba/media.txt |
banner:banner |
/opt/dba → /var/www/html
|
Constraint: The playbook must run with:
ansible-playbook -i inventory playbook.yml
No extra arguments allowed.
🧠 The Critical Insight
The requirement reads:
"Create a symbolic link of source path
/opt/dbato destination/var/www/html"
This means:
-
Source (
src) =/opt/dba→ the target being pointed to -
Destination (
dest) =/var/www/html→ the link itself
So /var/www/html becomes a symlink pointing to /opt/dba. That's why files inside /opt/dba are accessible directly at /var/www/html/<file>.
⚠️ The Trap
A first attempt created:
/var/www/html/devops → /opt/dba
That's wrong — because it means the validator looking for blog.txt under /var/www/html would not find it (the file was at /var/www/html/devops/blog.txt).
✅ The Correct Layout
/var/www/html → /opt/dba
Now blog.txt is directly reachable at /var/www/html/blog.txt.
🛠️ The Playbook
File: /home/thor/ansible/playbook.yml
---
- name: Create files and soft links on all app servers
hosts: all
gather_facts: no
tasks:
- name: Create /opt/dba directory
file:
path: /opt/dba
state: directory
mode: '0755'
become: yes
- name: Create empty file with correct ownership on each app server
file:
path: "/opt/dba/{{ item.file }}"
state: touch
owner: "{{ item.owner }}"
group: "{{ item.owner }}"
mode: '0644'
become: yes
loop:
- { file: 'blog.txt', owner: 'tony', server: 'stapp01' }
- { file: 'story.txt', owner: 'steve', server: 'stapp02' }
- { file: 'media.txt', owner: 'banner', server: 'stapp03' }
when: inventory_hostname == item.server
- name: Remove existing /var/www/html path
file:
path: /var/www/html
state: absent
become: yes
- name: Create symlink /var/www/html pointing to /opt/dba
file:
src: /opt/dba
dest: /var/www/html
state: link
force: yes
become: yes
🔍 Task-by-Task Breakdown
Task 1 — Create the source directory
- name: Create /opt/dba directory
file:
path: /opt/dba
state: directory
mode: '0755'
become: yes
Ensures /opt/dba exists before any files or symlinks are created. become: yes is needed to write under /opt.
Task 2 — Create files with server-specific ownership
- name: Create empty file with correct ownership on each app server
file:
path: "/opt/dba/{{ item.file }}"
state: touch
owner: "{{ item.owner }}"
group: "{{ item.owner }}"
mode: '0644'
become: yes
loop:
- { file: 'blog.txt', owner: 'tony', server: 'stapp01' }
- { file: 'story.txt', owner: 'steve', server: 'stapp02' }
- { file: 'media.txt', owner: 'banner', server: 'stapp03' }
when: inventory_hostname == item.server
How it works:
- Loops over all three server definitions.
-
when: inventory_hostname == item.serverguarantees only the matching file is created on each server. -
state: touchcreates the file if missing (or updates the timestamp if it exists — fully idempotent). - Ownership is set dynamically from the loop item.
This pattern elegantly handles "different config per host" without separate plays.
Task 3 — Remove any existing /var/www/html
- name: Remove existing /var/www/html path
file:
path: /var/www/html
state: absent
become: yes
Why this step matters:
- On many systems
/var/www/htmlexists as a real directory (Apache document root). - You cannot create a symlink at a path that is already a real directory — Ansible will fail with
File exists. - Removing it first ensures the next task can cleanly create the symlink.
- On the initial run, this task returned
ok(nothing existed yet). - On subsequent runs, it stays idempotent.
Task 4 — Create the symlink
- name: Create symlink /var/www/html pointing to /opt/dba
file:
src: /opt/dba
dest: /var/www/html
state: link
force: yes
become: yes
The key parameters:
| Parameter | Value | Meaning |
|---|---|---|
src |
/opt/dba |
What the link points to |
dest |
/var/www/html |
The link file itself |
state |
link |
Create a symbolic link |
force |
yes |
Replace existing link if present (idempotent) |
🚀 Execution
cd /home/thor/ansible
ansible-playbook -i inventory playbook.yml
Output
PLAY [Create files and soft links on all app servers] **********************************
TASK [Create /opt/dba directory] *******************************************************
ok: [stapp03]
ok: [stapp02]
ok: [stapp01]
TASK [Create empty file with correct ownership on each app server] *********************
skipping: [stapp02] => (item={'file': 'blog.txt', 'owner': 'tony', 'server': 'stapp01'})
skipping: [stapp03] => (item={'file': 'blog.txt', 'owner': 'tony', 'server': 'stapp01'})
skipping: [stapp03] => (item={'file': 'story.txt', 'owner': 'steve', 'server': 'stapp02'})
changed: [stapp03] => (item={'file': 'media.txt', 'owner': 'banner', 'server': 'stapp03'})
changed: [stapp01] => (item={'file': 'blog.txt', 'owner': 'tony', 'server': 'stapp01'})
skipping: [stapp01] => (item={'file': 'story.txt', 'owner': 'steve', 'server': 'stapp02'})
skipping: [stapp01] => (item={'file': 'media.txt', 'owner': 'banner', 'server': 'stapp03'})
changed: [stapp02] => (item={'file': 'story.txt', 'owner': 'steve', 'server': 'stapp02'})
skipping: [stapp02] => (item={'file': 'media.txt', 'owner': 'banner', 'server': 'stapp03'})
TASK [Remove existing /var/www/html path] **********************************************
ok: [stapp03]
ok: [stapp02]
ok: [stapp01]
TASK [Create symlink /var/www/html pointing to /opt/dba] *******************************
changed: [stapp03]
changed: [stapp01]
changed: [stapp02]
PLAY RECAP *****************************************************************************
stapp01 : ok=4 changed=2 unreachable=0 failed=0 skipped=0
stapp02 : ok=4 changed=2 unreachable=0 failed=0 skipped=0
stapp03 : ok=4 changed=2 unreachable=0 failed=0 skipped=0
Reading the output:
- Task 1:
okon all servers (directory already existed). - Task 2:
changedonce per server (each got its own file);skippedfor the two irrelevant loop items per host — exactly the intended behavior. - Task 3:
ok(nothing to remove yet on first run). - Task 4:
changedon all three (symlink created). - 0 failed, 0 unreachable ✅
✅ Verification
Syntax check
ansible-playbook -i inventory playbook.yml --syntax-check
playbook: playbook.yml
Verify the symlink
ansible -i inventory all -m shell -a "ls -ld /var/www/html && readlink /var/www/html" --become
stapp03 | CHANGED | rc=0 >>
lrwxrwxrwx 1 root root 8 Nov 1 08:59 /var/www/html -> /opt/dba
/opt/dba
stapp02 | CHANGED | rc=0 >>
lrwxrwxrwx 1 root root 8 Nov 1 08:59 /var/www/html -> /opt/dba
/opt/dba
stapp01 | CHANGED | rc=0 >>
lrwxrwxrwx 1 root root 8 Nov 1 08:59 /var/www/html -> /opt/dba
/opt/dba
✅ /var/www/html is a symlink pointing to /opt/dba on all three servers.
Verify files and ownership
ansible -i inventory stapp01 -m shell -a "ls -l /var/www/html/blog.txt" --become
ansible -i inventory stapp02 -m shell -a "ls -l /var/www/html/story.txt" --become
ansible -i inventory stapp03 -m shell -a "ls -l /var/www/html/media.txt" --become
stapp01 | CHANGED | rc=0 >>
-rw-r--r-- 1 tony tony 0 Nov 1 08:59 /var/www/html/blog.txt
stapp02 | CHANGED | rc=0 >>
-rw-r--r-- 1 steve steve 0 Nov 1 08:59 /var/www/html/story.txt
stapp03 | CHANGED | rc=0 >>
-rw-r--r-- 1 banner banner 0 Nov 1 08:59 /var/www/html/media.txt
✅ Each file exists at the expected path with the correct owner/group:
-
blog.txt→ tony -
story.txt→ steve -
media.txt→ banner
Note how the files are reached through the symlink at /var/www/html — proving the symlink works and the path matches validator expectations.
📊 Before vs After
| Check | Before | After |
|---|---|---|
/opt/dba directory |
❌ missing | ✅ created |
blog.txt (stapp01) |
❌ missing | ✅ owned by tony |
story.txt (stapp02) |
❌ missing | ✅ owned by steve |
media.txt (stapp03) |
❌ missing | ✅ owned by banner |
/var/www/html |
real dir or absent | ✅ symlink → /opt/dba
|
File reachable at /var/www/html/<file>
|
❌ | ✅ yes |
| Playbook idempotent | — | ✅ yes |
💡 Key Learnings
1. src vs dest for symlinks
file:
src: /opt/dba # the TARGET
dest: /var/www/html # the LINK itself
state: link
Think of dest as "where the link lives" and src as "where it points".
2. You can't symlink over a real directory
If /var/www/html exists as a directory, state: link fails with File exists. Removing it first (state: absent) is essential.
3. loop + when = per-host configuration
loop:
- { file: 'blog.txt', owner: 'tony', server: 'stapp01' }
...
when: inventory_hostname == item.server
This is a clean way to apply distinct values to distinct hosts using one task.
4. state: touch is idempotent
It creates the file if missing and updates timestamps otherwise — never fails on re-runs.
5. force: yes on links
Ensures the task replaces stale or wrong links, keeping the playbook idempotent.
6. Heredoc pitfalls
The interrupted EOF earlier caused shell errors like state:: command not found. Always verify with cat playbook.yml before running after a heredoc write.
🎉 Conclusion
The playbook successfully completed all requirements:
- ✅ Created
/opt/dbaon every server - ✅ Created
blog.txt,story.txt,media.txtwith correct owner/group per server - ✅ Created symlink
/var/www/html → /opt/dbaon all servers - ✅ Files are accessible at
/var/www/html/<file>with correct ownership - ✅ Playbook is idempotent and passes the syntax check
- ✅ Runs with
ansible-playbook -i inventory playbook.yml(no extra args)
The Nautilus DevOps team now has a reusable, idempotent playbook for managing per-server files and symlinked document roots — a great stepping stone toward more complex deployment automation.
Top comments (0)