DEV Community

Janak Shrestha
Janak Shrestha

Posted on

Creating Soft Links Using Ansible

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:

  1. Create an empty file /opt/dba/blog.txt on app server 1; its user owner and group owner should be tony. Create a symbolic link of source path /opt/dba to destination /var/www/html.

  2. Create an empty file /opt/dba/story.txt on app server 2; its user owner and group owner should be steve. Create a symbolic link of source path /opt/dba to destination /var/www/html.

  3. Create an empty file /opt/dba/media.txt on app server 3; its user owner and group owner should be banner. Create a symbolic link of source path /opt/dba to 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
Enter fullscreen mode Exit fullscreen mode

No extra arguments allowed.


🧠 The Critical Insight

The requirement reads:

"Create a symbolic link of source path /opt/dba to 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🔍 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

How it works:

  • Loops over all three server definitions.
  • when: inventory_hostname == item.server guarantees only the matching file is created on each server.
  • state: touch creates 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
Enter fullscreen mode Exit fullscreen mode

Why this step matters:

  • On many systems /var/www/html exists 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Reading the output:

  • Task 1: ok on all servers (directory already existed).
  • Task 2: changed once per server (each got its own file); skipped for the two irrelevant loop items per host — exactly the intended behavior.
  • Task 3: ok (nothing to remove yet on first run).
  • Task 4: changed on all three (symlink created).
  • 0 failed, 0 unreachable ✅

✅ Verification

Syntax check

ansible-playbook -i inventory playbook.yml --syntax-check
Enter fullscreen mode Exit fullscreen mode
playbook: playbook.yml
Enter fullscreen mode Exit fullscreen mode

Verify the symlink

ansible -i inventory all -m shell -a "ls -ld /var/www/html && readlink /var/www/html" --become
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

✅ /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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

✅ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/dba on every server
  • ✅ Created blog.txt, story.txt, media.txt with correct owner/group per server
  • ✅ Created symlink /var/www/html → /opt/dba on 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.


📚 Resources

Top comments (0)