Skip to main content

Ansible Playground: Example Playbook & Role Structure, Collection of Common Used Ansible Tasks, Ansible Tags and Handlers

2307 words·
Ansible Debian
Table of Contents

Ansible Installation (Debian)
#

Official Documentation: https://docs.ansible.com/ansible/latest/installation_guide/installation_distros.html#installing-ansible-on-debian

# Install requirements
sudo apt install gnupg -y

Ansible installation script:

# Export Ubuntu version:
UBUNTU_CODENAME=jammy  # Ubuntu 22
UBUNTU_CODENAME=noble  # Ubuntu 24

# Download and add the Ansible GPG key
wget -O- "https://keyserver.ubuntu.com/pks/lookup?fingerprint=on&op=get&search=0x6125E2A8C77F2818FB7BD15B93C4A3FD7BB9C367" \
  | sudo gpg --dearmour -o /usr/share/keyrings/ansible-archive-keyring.gpg

# Add Ansible repository
echo "deb [signed-by=/usr/share/keyrings/ansible-archive-keyring.gpg] http://ppa.launchpad.net/ansible/ansible/ubuntu $UBUNTU_CODENAME main" \
  | sudo tee /etc/apt/sources.list.d/ansible.list

# Install Ansible
sudo apt update && sudo apt install ansible -y
# Verify installation / list version
ansible --version



Ansible Playground
#

File & Folder Structure
#

# The file and folder structure looks like this:
ansible-playground
├── ansible.cfg
├── inventory
├── playbooks
│   └── example_playbook.yml
└── roles
    └── example_role
        └── tasks
            └── main.yml
# Create the folder structure
mkdir -p \
  ansible-playground/playbooks \
  ansible-playground/roles/example_role/tasks \
  && cd ansible-playground

Ansible Configuration
#

  • ansible.cfg
[defaults]
roles_path = ./roles

Inventory File
#

  • inventory
[example_hosts]
192.168.30.161 ansible_user=debian

Example Playbook
#

  • playbooks/example_playbook.yml
---
- name: Example Playbook
  hosts: example_hosts
  become: true
  gather_facts: true

  roles:
    - example_role

Example Role
#

  • roles/example_role/tasks/main.yml

  • Add the tasks to the main.yml file.

  • All tasks were tested on Debian 12 server.


Task: Create User
#

  • This task creates a new user with home directory, adds the user to the sudo and docker group, allowes the user to uses sudo without password and adds a public SSH key to the “~/.ssh/authorized_keys” file of the user.
---
- name: Ensure user exists
  ansible.builtin.user:
    name: example-user
    shell: /bin/bash
    home: /home/example-user
    groups: sudo, docker
    append: yes  # Add user to groups, keep user in any other groups the user might already be part of
    expires: -1  # No expiration
    state: present
    create_home: yes

- name: Add public SSH key to authorized_keys
  ansible.posix.authorized_key:
    user: example-user
    state: present
    key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC56iSOcPyN6CGW4RAzcv7w1YxO3wJMY9mC1lEAT5W6SgnjiUjQFstQyNCJJcrPNHYF1sjMfbN8lThY/b007fwVh9XQX7tnVZ6rQ5CU1VrmgMzyMPkcLPoIaVTWSggDRu/PUtrBtcURje+HZQno9oRYyA768l6acG0PSHwftRyyKcJ4TE/4Iv6tmQPXi5HKt4aIRy1MYqtMR3FcKd+enCx0I9TM1+eSL/vxfYS+e2MZJwnz/Zl8HJxBg/XRfti7FIydy2CvPszQkIvFrMpP8BQhvNHtIWDzooabLnxuAv7//xlWwwC6GXBeQ+UxWmn6VUexpN6Hr0WUqI7ErmODhPLqzXtOPHhfq8MC2SAtZxx9+b+yjpjT0rlDnm1Fv9MWi0kEiYiYnJxsJZZiEKAxwUSb0pz/uneU1Cj66A6xoFNCwG3H6bW7gySGtKWtjrjM2EmwzRAvA7h4xZufLns0tPx4MhR9AaounWpdj1vaJhJiHAACG2scmJMjUYhMTuY9lIUUW9viak/z8VlBsKCgve+oSRZrXg84kleHR8eREeAtDwQdPEtxXgNID2B9v2XwDYVo3Lgl41tSiloA7TU/49GG6SvCsyVfW1r2AdlI/QBoncoP3OxixxfDNauVg7omJ0W8Fh1MSb7h+G5Hq8mV2l8L1s75yPTVnmOrMt3jfIjlEQ== debian@debansible01"

- name: Allow user to use sudo without a password
  ansible.builtin.copy:
    dest: /etc/sudoers.d/example-user
    content: "example-user ALL=(ALL) NOPASSWD:ALL\n"
    mode: '0440'
    owner: root
    group: root

Task: Delete User
#

Delete the user but keep the users home directory:

---
- name: Remove user account and home directory
  ansible.builtin.user:
    name: example-user
    state: absent

Delete the user and the users home directory:

---
- name: Remove user account and home directory
  ansible.builtin.user:
    name: example-user
    state: absent
    remove: yes # Delete home directory

Task: Create Group
#

---
- name: Ensure group exists
  ansible.builtin.group:
    name: example-group
    gid: 1010
    state: present

Task: Create Directory
#

---
- name: Ensure directory exists
  ansible.builtin.file:
    path: /opt/example-directory
    state: directory
    owner: debian
    group: debian
    recurse: yes
  become: true

Task: Create File with Content
#

---
- name: Ensure file exists
  ansible.builtin.copy:
    dest: /tmp/example-file
    content: |
      some text
      more text      
    owner: debian
    group: debian

Task: Create Symlink #

- name: Create symlink
  ansible.builtin.file:
    src: /tmp/some-script
    dest: /usr/local/bin/some-script
    state: link
  become: true

Task: Copy Static File
#

# The file and folder structure looks like this:
ansible-playground
├── ansible.cfg
├── inventory
├── playbooks
│   └── example_playbook.yml
└── roles
    └── example_role
        ├── files
        │   └── example-file.txt
        └── tasks
            └── main.yml
# Create the folder structure
mkdir -p roles/example_role/files
# Create static file that will be copied
touch roles/example_role/files/example-file.txt
  • roles/example_role/tasks/main.yml
- name: configure gitlab docker dnd runner
  copy:
    src: example-file.txt
    dest: /tmp/destination-file.txt
    owner: debian
    group: debian
    mode: "600"

Task: Copy Several Static Files
#

# The file and folder structure looks like this:
ansible-playground
├── ansible.cfg
├── inventory
├── playbooks
│   └── example_playbook.yml
└── roles
    └── example_role
    │   ├── files
    │   │   ├── file1.txt
    │   │   ├── file2.txt
    │   │   └── file3.txt
        └── tasks
            └── main.yml
# Create the folder structure
mkdir -p roles/example_role/files
# Create static file that will be copied
touch roles/example_role/files/file1.txt
touch roles/example_role/files/file2.txt
touch roles/example_role/files/file3.txt
  • roles/example_role/tasks/main.yml
---
- name: Ensure files exist
  copy:
    src: "{{ item }}"
    dest: "/tmp/{{ item }}"
  loop:
    - file1.txt
    - file2.txt
    - file3.txt



Task: Copy File from Template
#

# The file and folder structure looks like this:
ansible-playground
├── ansible.cfg
├── inventory
├── playbooks
│   └── example_playbook.yml
└── roles
    └── example_role
        ├── tasks
        │   └── main.yml
        └── templates
            └── config.toml.j2 # template file
# Create the folder structure
mkdir -p roles/example_role/templates

Create the template file:

  • roles/example_role/templates/config.toml.j2
concurrent = 5
check_interval = 0
shutdown_timeout = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "example-runner"
  url = {{ gitlab_runner_url }}
  id = 1
  token = {{ gitlab_runner_token }}
  executor = "docker"

Define the varibales in the playbook:

  • playbooks/example_playbook.yml
---
- name: Example Playbook
  hosts: example_hosts
  become: true
  gather_facts: true
  # Define the variables that are used by the template
  vars:
    gitlab_runner_url: "https://gitlab.example.com/"
    gitlab_runner_token: "SOME-TOKEN"

  roles:
    - example_role

Create the task thak copies the file from the template:

  • roles/example_role/tasks/main.yml
---
- name: Ensure GitLab Runner config directory exists
  file:
    path: /etc/gitlab-runner
    state: directory
    owner: root
    group: root
    mode: '0755'

- name: Deploy GitLab Runner config from template
  template:
    src: config.toml.j2
    dest: /etc/gitlab-runner/config.toml
    owner: root
    group: root
    mode: '0644'



Task: Clone Git Repository
#

The following task clones a git repository from GitHub and pulls the latest version, every time the playbook triggers the task:

---
- name: Ensure git repository exists
  ansible.builtin.git:
    repo: https://github.com/octocat/Hello-World.git
    dest: /tmp/github-repository # The content of the git repo gets cloned into this directory
    version: master
    update: yes
    clone: yes
    force: yes
  become_user: debian

Task: Download File (if it does not exist)
#

The following task checks if a files exists locally and if not downloads it:

---
## Verify if script exists locally
- name: Verify if script exists locally
  stat:
    path: /tmp/gitlab-runner.script.deb.sh
  register: example_script

# Fetch script if it does not exist locally
- name: Fetch script if it does not exist locally
  ansible.builtin.get_url:
    url: https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh
    dest: /tmp/gitlab-runner.script.deb.sh
    mode: "0744"
  when: not example_script.stat.exists

Note: In this example it would make more sense to reference the path, that is created via the script when it is later executed, like /etc/apt/sources.list.d/runner_gitlab-runner.list.


Task: Install a Package & Packages
#

Install a single package:

- name: Ensure package is installed
  ansible.builtin.apt:
    name: gitlab-runner
    state: latest
    update_cache: true
    cache_valid_time: 0

Install several packages:

---
- name: Ensure packages are installed
  ansible.builtin.apt:
    name: "{{ item }}"
    state: latest
    update_cache: true
    cache_valid_time: 0
  loop:
    - sudo
    - git
    - curl
    - neovim

Task: Uninstall Package
#

  • The task is idempotent, if the package is already uninstalled, nothing happens.

Uninstall a package:

---
- name: Uninstall package
  ansible.builtin.apt:
    name: ufw
    state: absent
    purge: yes # Remove the package and its configuration files

Uninstall several packages:

- name: Uninstall packages
  ansible.builtin.apt:
    name: "{{ item }}"
    state: absent
    purge: yes # Remove the package and its configuration files
  loop:
    - curl
    - ufw
    - git

Task: Install & Configure UFW Firewall
#

  • Using state: reset clears all existing firewall rules before applying new ones. This ensures that the firewall configuration matches exactly what is defined in the playbook.

  • to remove a port, simply delete it from the list and rerun the playbook.

- name: Ensure UFW firewall is installed
  ansible.builtin.apt:
    name: ufw
    state: latest  # If already installed, update to the latest version
    update_cache: yes  # Update the APT cache

- name: Reset UFW firewall rules
  community.general.ufw:
    state: reset

- name: Add UFW firewall rules
  community.general.ufw:
    rule: allow
    port: "{{ item }}"
    proto: tcp
  loop:
    - 22
    - 80
    - 443

- name: Ensure UFW is enabled
  community.general.ufw:
    state: enabled
    policy: deny  # Everything is blocked by default

Task: Install Docker & Docker Compose
#

---
# Install required dependencies for Docker
- name: Install dependencies
  ansible.builtin.apt:
    name: "{{ item }}"
    state: latest
    update_cache: true
    cache_valid_time: 0
  loop:
    - ca-certificates
    - curl
    - git
    - gnupg
    - lsb-release


# Install Docker & Docker Compose
- name: Ensure /etc/apt/keyrings directory exists
  ansible.builtin.file:
    path: /etc/apt/keyrings
    state: directory
    mode: '0755'

- name: Add Docker GPG key
  ansible.builtin.get_url:
    url: https://download.docker.com/linux/debian/gpg
    dest: /etc/apt/keyrings/docker.asc
    mode: '0644'

- name: Add Docker APT repository
  ansible.builtin.apt_repository:
    repo: "deb [arch={{ (ansible_architecture == 'aarch64') | ternary('arm64', (ansible_architecture == 'x86_64') | ternary('amd64', ansible_architecture)) }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable"
    state: present
    filename: docker

- name: Install docker
  ansible.builtin.apt:
    name: "{{ item }}"
    state: present
    update_cache: true
    cache_valid_time: 0
  loop:
    - docker-ce
    - docker-ce-cli
    - containerd.io
    - docker-buildx-plugin
    - docker-compose-plugin


# Add user to Docker group
- name: Ensure user is in the Docker group
  ansible.builtin.user:
    name: debian
    groups: docker
    append: yes

# Reboot
- name: Reboot if needed (optional)
  ansible.builtin.reboot:
    msg: "Rebooting to apply docker group membership"
    reboot_timeout: 120
  when: reboot_required | default(true)

Task: Start Docker Container
#

---
- name: Start Docker container
  docker_container:
    name: nginx
    image: "nginx:latest"
    restart_policy: unless-stopped
    ports:
      - "8080:80"

Task: Start Docker Container with Volume Mapping
#

---
- name: Ensure directory exists
  ansible.builtin.file:
    path: /opt/nginx/data
    state: directory
    mode: '0755'
    owner: debian
    group: debian
    recurse: yes
  become: true

- name: Ensure file exists
  ansible.builtin.copy:
    dest: /opt/nginx/data/index.html
    content: |
      hi there
      some text      
    owner: debian
    group: debian

- name: Start a Docker container with volume mapping
  docker_container:
    name: nginx
    image: "nginx:latest"
    restart_policy: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - "/opt/nginx/data:/usr/share/nginx/html"

Task: Stop Docker Container
#

---
- name: Stop Docker container without removing it
  docker_container:
    name: nginx
    state: stopped

Task: Stop & Remove Docker Container
#

# Start container with  the name "nginx"
docker run --name nginx -d -p 8080:80 nginx:latest

---
- name: Stop and remove Docker container
  docker_container:
    name: nginx
    state: absent

Task: Remove Container Image
#

- name: Remove Nginx image
  docker_image:
    name: nginx
    tag: latest
    state: absent

Task: Start Docker Compose Stack
#

  • This task creates a “docker-compose.yml” file with the specified content. As an alternative, the file could be copied from a static file or template.

  • The stack is started using a command, which is not idempotent. The task runs even if the stack is already up, and Ansible will always report a “changed” status.

---
- name: Ensure directory exists
  ansible.builtin.file:
    path: /opt/nginx-stack
    state: directory
    owner: debian
    group: debian
    recurse: yes
  become: true

- name: Ensure file exists
  ansible.builtin.copy:
    dest: /opt/nginx-stack/docker-compose.yml
    content: |
      services:
        nginx:
          image: nginx:latest
          restart: unless-stopped
          ports:
            - "8080:80"      
    owner: debian
    group: debian
  become: true

- name: Start Docker Compose stack
  ansible.builtin.command: docker compose up -d
  args:
    chdir: /opt/nginx-stack
  become: true
  become_user: debian

Task: Stop Docker Compose Stack
#

---
- name: Stop Docker Compose stack
  ansible.builtin.command: docker compose down
  args:
    chdir: /opt/nginx-stack
  become: true

Task: Check Compose Status & Stop Stack
#

- name: Check if Docker Compose steck is up
  ansible.builtin.command: docker compose ps
  register: compose_status
  failed_when: false
  changed_when: false
  args:
    chdir: /opt/nginx-stack
  become: yes

- name: Stop Docker Compose stack
  ansible.builtin.command: docker compose down --remove-orphans
  args:
    chdir: /opt/nginx-stack
  when: "'Up' in compose_status.stdout"
  become: yes

Task - Services: Start Service
#

- name: Start service
  ansible.builtin.systemd:
    name: nginx
    state: started

Task - Services: Restart Service
#

- name: Restart service
  ansible.builtin.systemd:
    name: nginx
    state: restarted

Task - Services: Stop Service
#

- name: Stop service
  ansible.builtin.systemd:
    name: nginx
    state: stopped

Task - Services: Enable Service
#

- name: Enable service at boot
  ansible.builtin.systemd:
    name: nginx
    enabled: yes

Task - Services: Disable Service
#

- name: Disable service at boot
  ansible.builtin.systemd:
    name: nginx
    enabled: no

Task - Shell Commands: Run Command (once)
#

This task copies /tmp/file1 to /tmp/file2 only if /tmp/file2 does not yet exist. The if condition makes it idempotent, the playbook notes the task still as changed though, because Ansible has no way to know that nothing was actually changed.

- name: Run command
  ansible.builtin.shell: |
    if ! ls /tmp/file2 >/dev/null 2>&1; then
      cp /tmp/file1 /tmp/file2
    fi    

Syntax:

  • If ls finds the file, it exits with code 0, and the condition is true.

  • ! inverts the condition, if ls /tmp/file2 fails (file does not exist), then it runs the cp command.


Task - Shell Commands: Run Script (once)
#

- name: Run a script
  ansible.builtin.command: bash /tmp/gitlab-runner.script.deb.sh
  args:
    creates: /etc/apt/sources.list.d/runner_gitlab-runner.list

Note: The script creates the following file /etc/apt/sources.list.d/runner_gitlab-runner.list and does not run again, if it already exists.


Task - K8s (Shell): Create Namespace (once)
#

- name: Create namespace
  ansible.builtin.shell: |
    if ! kubectl get namespace example-namespace >/dev/null 2>&1; then
      kubectl create namespace example-namespace
    fi    

Task - K8s (Shell): Create Secret (once)
#

- name: Create secret from yml
  ansible.builtin.shell: |
    kubectl get secret secret-name -n example-namespace || \
    kubectl apply -f secret.yml    
  args:
    chdir: /some/directory

Task - K8s (Shell): Kubectl Apply (with timeout)
#

- name: Kubectl apply with timeout
  ansible.builtin.shell: kubectl apply -f some.yml --timeout=120s
  args:
    chdir: /some/directory

Task - K8s (Shell): Deploy Helm Release (once)
#

- name: Deploy Helm Chart
  ansible.builtin.shell: |
    export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
    if ! helm status release-name -n example-namespace >/dev/null 2>&1; then
      helm install release-name repository/chart-name \
        --namespace example-namespace \
        -f values.yml
    fi    
  args:
    chdir: /some/directory



Task with Handler
#

The following task triggers a handler that restarts a server.

# The file and folder structure looks like this:
ansible-playground
├── ansible.cfg
├── inventory
├── playbooks
│   └── example_playbook.yml
└── roles
    └── example_role
        ├── handlers
        │   └── main.yml
        ├── tasks
        │   └── main.yml
        └── templates
            └── config.toml.j2

  • roles/example_role/tasks/main.yml
# Copy NFS exports file from template
- name: Copy NFS exports file from template
  template:
    src: exports.j2
    dest: /etc/exports
    owner: root
    group: root
    mode: "600"
  notify: Restart NFS server

  • roles/example_role/handlers/main.yml
# Restart NFS server
- name: Restart NFS server
  ansible.builtin.systemd:
    name: nfs-server
    state: restarted
    enabled: true



Run Playbook
#

Run Example Playbook
#

# CD into Ansible project
cd ~/ansible-playground

# Run the playbook
ansible-playbook playbooks/example_playbook.yml -i inventory

Run Playbook with Tags
#

  • playbooks/example_playbook.yml
---
- name: Example Playbook
  hosts: example_hosts
  become: true
  gather_facts: true

  roles:
    - example_role

  • roles/example_role/tasks/main.yml
---
- name: Folder 1
  ansible.builtin.file:
    path: /tmp/folder1
    state: directory
  tags: [tag1]

- name: Folder 2
  ansible.builtin.file:
    path: /tmp/folder2
    state: directory
  tags: [tag2]

- name: Folder 3
  ansible.builtin.file:
    path: /tmp/folder3
    state: directory
  tags: [tag2, tag3]

Run the playbook:

# Run playbook: Default run (no tag filtering, run all tasks)
ansible-playbook playbooks/example_playbook.yml -i inventory

# Run playbook: Run specific tag only (only /tmp/folder3 is created)
ansible-playbook playbooks/example_playbook.yml --tags "tag3" -i inventory

# Run playbook: Skip specific tag  (only /tmp/folder1 is created)
ansible-playbook playbooks/example_playbook.yml --skip-tags "tag2" -i inventory


# Define multiple tags (All folders are created)
ansible-playbook playbooks/example_playbook.yml --tags "tag1,tag2" -i inventory