DevOpsil

Ansible Tags: Selective Task Execution For Faster Deployments

Asif MuzammilAsif Muzammil14 min read

Ansible Tags: Selective Task Execution for Faster Deployments

If you've ever run a full Ansible playbook just to push a single config change and watched it grind through 47 tasks before touching the one you care about — you already understand why tags exist.

Ansible tags are one of those features that seem simple on the surface but have real depth when you start using them in production. Done right, they transform your playbooks from monolithic scripts into surgical tools. Done wrong, they become a maintenance nightmare that nobody trusts. This guide covers both sides of that coin.


What Are Ansible Tags and Why Should You Care?

Tags are labels you attach to tasks, roles, blocks, or even entire plays. When you run a playbook with --tags, Ansible only executes the tagged items. When you use --skip-tags, it runs everything except those items.

That's the simple version. The practical implication is enormous: in a mature infrastructure codebase, your playbooks might manage OS hardening, package installation, service configuration, firewall rules, and application deployment all in one run. With tags, a developer can redeploy an app config without touching the firewall. An ops engineer can run just the hardening tasks on a new server. A CI pipeline can apply only the changes relevant to a specific service.

Tags are the difference between a playbook that takes 20 minutes and one that takes 90 seconds — for the same desired outcome.


Basic Tag Syntax

Let's start with the fundamentals before getting into the sophisticated patterns.

Tagging Individual Tasks

---
- name: Configure web server
  hosts: webservers
  tasks:
    - name: Install nginx
      ansible.builtin.package:
        name: nginx
        state: present
      tags:
        - nginx
        - packages
        - install

    - name: Deploy nginx configuration
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
      tags:
        - nginx
        - config
        - deploy

    - name: Start and enable nginx
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true
      tags:
        - nginx
        - services

Now you can run just the config deployment:

ansible-playbook site.yml --tags "config"

Or skip package installation on servers where you know packages are already up to date:

ansible-playbook site.yml --skip-tags "packages"

Inline Tag Syntax

For cleaner playbooks, tags can be written inline when you only have one:

- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present
  tags: packages

I personally prefer the list format even for single tags — it's easier to add more later without reformatting, and your diffs stay cleaner.


Tagging at Different Levels

One of the most powerful aspects of Ansible tags is that they're hierarchical. Tags propagate downward.

Tagging Blocks

Blocks let you group related tasks and apply tags to all of them at once:

- name: Configure application
  hosts: appservers
  tasks:
    - name: Application setup block
      tags:
        - app
        - setup
      block:
        - name: Create app user
          ansible.builtin.user:
            name: appuser
            system: true
            shell: /sbin/nologin

        - name: Create app directories
          ansible.builtin.file:
            path: "{{ item }}"
            state: directory
            owner: appuser
            mode: '0750'
          loop:
            - /opt/myapp
            - /opt/myapp/config
            - /opt/myapp/logs

        - name: Deploy application binary
          ansible.builtin.copy:
            src: myapp
            dest: /opt/myapp/myapp
            owner: appuser
            mode: '0755'

All three tasks inherit the app and setup tags. Individual tasks can still have their own additional tags on top of what the block provides.

Tagging Entire Plays

---
- name: Configure load balancers
  hosts: loadbalancers
  tags:
    - lb
    - infrastructure
  tasks:
    - name: Install HAProxy
      ansible.builtin.package:
        name: haproxy
        state: present

    - name: Configure HAProxy
      ansible.builtin.template:
        src: haproxy.cfg.j2
        dest: /etc/haproxy/haproxy.cfg
      notify: Restart HAProxy

- name: Configure application servers
  hosts: appservers
  tags:
    - app
    - application
  tasks:
    - name: Deploy application
      ansible.builtin.copy:
        src: app.tar.gz
        dest: /opt/app/

Running --tags lb will execute the entire load balancer play, including all its tasks.

Tagging Roles

---
- name: Full stack deployment
  hosts: all
  roles:
    - role: common
      tags: common

    - role: security_hardening
      tags:
        - security
        - hardening

    - role: nginx
      tags:
        - nginx
        - webserver

    - role: myapp
      tags:
        - app
        - deploy

This is where tags really shine. You can target a single role in a complex playbook without touching anything else.


Special Built-in Tags

Ansible ships with two special tags that always exist regardless of what you've defined: always and never.

The always Tag

Tasks tagged with always run no matter what — even when you specify other tags:

- name: Gather facts
  ansible.builtin.setup:
  tags: always

- name: Load variable files
  ansible.builtin.include_vars:
    dir: vars/
  tags: always

- name: Check prerequisites
  ansible.builtin.assert:
    that:
      - ansible_os_family == "RedHat"
      - ansible_distribution_major_version | int >= 8
    fail_msg: "This playbook requires RHEL/CentOS 8+"
  tags: always

I use always for three categories of tasks: fact gathering, variable loading, and prerequisite checks. These need to run regardless of which operational subset you're executing. Skipping them causes subtle failures that are painful to debug.

The never Tag

Opposite of always — tasks tagged with never only run when you explicitly call them by tag:

- name: Reset application to factory defaults
  ansible.builtin.shell: /opt/app/reset.sh --confirm
  tags: never, reset

- name: Purge all cached data
  ansible.builtin.file:
    path: /var/cache/myapp
    state: absent
  tags:
    - never
    - purge_cache

- name: Run database migrations
  ansible.builtin.command: /opt/app/manage.py migrate
  tags:
    - never
    - migrate

The never tag is perfect for dangerous or infrequently used tasks that you want in the playbook for documentation and reusability, but that should never run accidentally. Database migrations, data purges, and service resets are classic use cases.

To run a never-tagged task, you have to explicitly ask for it:

ansible-playbook site.yml --tags "migrate"

Real-World Tag Architecture

Here's where most guides stop, and where the real work begins. Let me walk you through a tag strategy I've actually used in production.

The Layered Tag Strategy

The key insight is that tags should answer two questions independently:

  1. What component does this touch? (nginx, postgres, app, redis)
  2. What type of operation is this? (install, config, deploy, restart, validate)

This gives you a two-dimensional tagging system:

---
- name: Database server configuration
  hosts: dbservers
  tasks:
    - name: Install PostgreSQL packages
      ansible.builtin.package:
        name:
          - postgresql14-server
          - postgresql14-contrib
          - python3-psycopg2
        state: present
      tags:
        - postgres
        - install
        - packages

    - name: Initialize PostgreSQL database cluster
      ansible.builtin.command:
        cmd: postgresql-setup --initdb
        creates: /var/lib/pgsql/14/data/PG_VERSION
      tags:
        - postgres
        - install
        - init

    - name: Deploy PostgreSQL configuration
      ansible.builtin.template:
        src: postgresql.conf.j2
        dest: /var/lib/pgsql/14/data/postgresql.conf
        owner: postgres
        group: postgres
        mode: '0600'
      notify: Restart PostgreSQL
      tags:
        - postgres
        - config

    - name: Deploy pg_hba.conf
      ansible.builtin.template:
        src: pg_hba.conf.j2
        dest: /var/lib/pgsql/14/data/pg_hba.conf
        owner: postgres
        group: postgres
        mode: '0600'
      notify: Reload PostgreSQL
      tags:
        - postgres
        - config
        - security

    - name: Start and enable PostgreSQL
      ansible.builtin.service:
        name: postgresql-14
        state: started
        enabled: true
      tags:
        - postgres
        - services

    - name: Create application databases
      community.postgresql.postgresql_db:
        name: "{{ item.name }}"
        encoding: UTF-8
        state: present
      loop: "{{ postgres_databases }}"
      tags:
        - postgres
        - databases
        - setup

    - name: Create application users
      community.postgresql.postgresql_user:
        name: "{{ item.name }}"
        password: "{{ item.password }}"
        state: present
      loop: "{{ postgres_users }}"
      no_log: true
      tags:
        - postgres
        - users
        - security
        - setup

Now your operators have real flexibility:

# Fresh server setup — run everything postgres-related
ansible-playbook site.yml --tags "postgres"

# Just push config changes (no package operations)
ansible-playbook site.yml --tags "config"

# Config changes only for postgres specifically
ansible-playbook site.yml --tags "postgres,config"

# Skip the initial setup tasks on existing servers
ansible-playbook site.yml --tags "postgres" --skip-tags "install,init"

# Security-related tasks across all components
ansible-playbook site.yml --tags "security"

Tags in CI/CD Pipelines

This is where tags deliver their most measurable ROI. A deployment pipeline that runs a 15-minute playbook on every commit creates friction and slows teams down. Surgical tag usage fixes this.

Here's a real pattern from a GitLab CI configuration:

# .gitlab-ci.yml
stages:
  - validate
  - deploy-config
  - deploy-app
  - smoke-test

validate-ansible:
  stage: validate
  script:
    - ansible-playbook site.yml --syntax-check
    - ansible-lint site.yml

deploy-config-changes:
  stage: deploy-config
  script:
    - ansible-playbook site.yml 
        --tags "config" 
        --limit "{{ target_env }}"
        -e "deployment_version={{ CI_COMMIT_SHA }}"
  only:
    changes:
      - roles/*/templates/**
      - roles/*/files/**
      - group_vars/**

deploy-application:
  stage: deploy-app
  script:
    - ansible-playbook site.yml 
        --tags "deploy,app" 
        --limit "{{ target_env }}"
        -e "app_version={{ CI_COMMIT_TAG }}"
  only:
    - tags

run-smoke-tests:
  stage: smoke-test
  script:
    - ansible-playbook site.yml 
        --tags "validate,smoke" 
        --limit "{{ target_env }}"
  when: on_success

The key here: your pipeline only runs the tasks relevant to what changed. A template change triggers a config-only run. An application release triggers the deploy tags. This keeps pipeline times honest.


Viewing Tags in Your Playbooks

Before you can use tags effectively, you need to see what's available. Ansible provides --list-tags for this:

ansible-playbook site.yml --list-tags

Output:

playbook: site.yml

  play #1 (webservers): Configure web servers  TAGS: []
      TASK TAGS: [always, config, deploy, install, nginx, packages, services]

  play #2 (dbservers): Configure database servers  TAGS: []
      TASK TAGS: [always, config, databases, install, postgres, security, services, setup, users]

You can also list tasks along with their tags:

ansible-playbook site.yml --list-tasks --tags "config"

This shows exactly what would execute, without executing anything. It's invaluable for verifying tag behavior before running in production.


Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting always on Critical Setup Tasks

The most common mistake I see is tagging everything but forgetting to make foundational tasks immune to filtering. When someone runs --tags deploy, they expect deployment to work — but if variable loading and fact gathering aren't tagged always, you get cryptic failures.

# Wrong — these will be skipped when using --tags
- name: Include environment variables
  ansible.builtin.include_vars:
    file: "{{ env }}.yml"

# Right — these always need to run
- name: Include environment variables
  ansible.builtin.include_vars:
    file: "{{ env }}.yml"
  tags: always

Pitfall 2: Tag Names That Don't Scale

Using vague tags like setup, misc, or other is a trap. Three months later, nobody remembers what's in there and people are afraid to use tags because they don't know what they'll hit.

Enforce a tag naming convention:

  • Component tags: lowercase component names (nginx, postgres, redis, app)
  • Operation tags: verbs or categories (install, config, deploy, restart, validate, cleanup)
  • Environment tags for plays that should only run in specific contexts (production, staging)

Document your tag taxonomy in your repository's README. Seriously. Do it.

Pitfall 3: Tag Abuse — Over-Tagging

Every task having 6 tags is just as bad as no tags. When everything is tagged everything, tags become meaningless.

Apply this filter before adding a tag: "Would someone realistically want to run this in isolation, or as part of a named group of tasks?" If the answer is no, don't tag it.

Pitfall 4: Tags on include_tasks vs import_tasks

This is a subtle but critical distinction. Tags behave differently depending on how you include tasks.

With import_tasks (static), tags on the import statement apply to all imported tasks:

# This works as expected — all tasks in setup.yml get the 'setup' tag
- name: Import setup tasks
  ansible.builtin.import_tasks: setup.yml
  tags: setup

With include_tasks (dynamic), tags on the include statement only control whether the include runs, not the individual tasks inside:

# The 'setup' tag only controls whether this line runs
# Tasks INSIDE setup.yml need their own tags
- name: Include setup tasks
  ansible.builtin.include_tasks: setup.yml
  tags: setup

This trips up almost everyone at least once. If you're using include_tasks and wondering why --tags doesn't filter tasks inside the included file, this is why.

Pitfall 5: Assuming Tags Are Inherited by Handlers

Handlers do not inherit tags from the tasks that notify them. A handler runs when notified, regardless of tag filtering. This is usually what you want, but be aware of it.

tasks:
  - name: Deploy nginx config
    ansible.builtin.template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
    notify: Restart nginx
    tags: config

handlers:
  - name: Restart nginx
    ansible.builtin.service:
      name: nginx
      state: restarted
    # No tags needed — handlers run when notified, tags don't filter them

Advanced Pattern: Tag-Driven Role Defaults

You can build tags into your role structure at a meta level. In a large organization, this pattern allows teams to own their tag namespaces:

# roles/nginx/tasks/main.yml
---
- name: Install nginx
  ansible.builtin.import_tasks: install.yml
  tags:
    - nginx
    - install

- name: Configure nginx
  ansible.builtin.import_tasks: configure.yml
  tags:
    - nginx
    - config

- name: Manage nginx service
  ansible.builtin.import_tasks: service.yml
  tags:
    - nginx
    - services

- name: Configure nginx virtual hosts
  ansible.builtin.import_tasks: vhosts.yml
  tags:
    - nginx
    - vhosts
    - config

Each subtask file handles its own specific operations, and the main task file provides the tag routing. This keeps each file focused and makes the tag structure self-documenting.


Testing Your Tag Strategy

Tags need to be tested just like code. Before relying on tags in production pipelines, verify them:

# Dry run with tags — see what would execute without doing it
ansible-playbook site.yml --tags "config" --check

# Dry run with diff — see what files would change
ansible-playbook site.yml --tags "config" --check --diff

# List what tags exist
ansible-playbook site.yml --list-tags

# List exactly which tasks would run for a given tag combination
ansible-playbook site.yml --list-tasks --tags "nginx,config"

I recommend adding a tag validation step to your CI pipeline that runs --list-tasks for your most common tag combinations and fails if the expected tasks aren't in the output. It's simple to implement and catches tag drift before it hits production.


A Complete Reference Playbook

Here's a production-ready playbook structure that demonstrates everything we've covered:

---
- name: Infrastructure baseline
  hosts: all
  gather_facts: false
  tags:
    - baseline

  tasks:
    - name: Gather facts
      ansible.builtin.setup:
      tags: always

    - name: Load environment-specific variables
      ansible.builtin.include_vars:
        file: "environments/{{ env }}.yml"
      tags: always

    - name: Validate target OS compatibility
      ansible.builtin.assert:
        that:
          - ansible_os_family in ['RedHat', 'Debian']
          - ansible_distribution_major_version | int >= 8
        fail_msg: "Unsupported OS: {{ ansible_distribution }} {{ ansible_distribution_major_version }}"
      tags: always

    - name: Configure system packages
      ansible.builtin.package:
        name: "{{ baseline_packages }}"
        state: present
      tags:
        - packages
        - install

    - name: Configure NTP
      ansible.builtin.template:
        src: chrony.conf.j2
        dest: /etc/chrony.conf
      notify: Restart chronyd
      tags:
        - ntp
        - config
        - time

    - name: Apply security hardening
      ansible.builtin.import_tasks: tasks/hardening.yml
      tags:
        - security
        - hardening

    - name: Configure firewall rules
      ansible.builtin.import_tasks: tasks/firewall.yml
      tags:
        - firewall
        - security
        - network

- name: Application deployment
  hosts: appservers
  tags:
    - application

  tasks:
    - name: Load application variables
      ansible.builtin.include_vars:
        file: "apps/{{ app_name }}.yml"
      tags: always

    - name: Create application user and group
      block:
        - name: Create application group
          ansible.builtin.group:
            name: "{{ app_user }}"
            system: true

        - name: Create application user
          ansible.builtin.user:
            name: "{{ app_user }}"
            group: "{{ app_user }}"
            system: true
            shell: /sbin/nologin
            home: "{{ app_home }}"
      tags:
        - app
        - setup
        - users

    - name: Deploy application artifacts
      ansible.builtin.unarchive:
        src: "{{ artifact_url }}"
        dest: "{{ app_home }}"
        remote_src: true
        owner: "{{ app_user }}"
      tags:
        - app
        - deploy
        - artifacts

    - name: Deploy application configuration
      ansible.builtin.template:
        src: "{{ item.src }}"
        dest: "{{ item.dest }}"
        owner: "{{ app_user }}"
        mode: "{{ item.mode | default('0644') }}"
      loop: "{{ app_config_files }}"
      notify: Restart application
      tags:
        - app
        - config
        - deploy

    - name: Manage application service
      ansible.builtin.service:
        name: "{{ app_service_name }}"
        state: started
        enabled: true
      tags:
        - app
        - services

    - name: Run post-deployment validation
      ansible.builtin.uri:
        url: "http://localhost:{{ app_port }}/health"
        status_code: 200
        timeout: 30
      retries: 5
      delay: 10
      tags:
        - app
        - validate
        - smoke

    # Dangerous tasks — never run unless explicitly called
    - name: Run database schema migrations
      ansible.builtin.command:
        cmd: "{{ app_home }}/manage migrate --no-input"
      become_user: "{{ app_user }}"
      tags:
        - never
        - migrate

    - name: Clear application cache
      ansible.builtin.file:
        path: "{{ app_cache_dir }}"
        state: absent
      tags:
        - never
        - clear_cache

Summary: Tag Strategy Principles

After everything above, here are the principles that hold up over time:

Be explicit, not implicit. Don't assume what tags will be used for. Name them clearly and document the intended usage.

Two dimensions: component and operation. Tags should tell you what and what kind of action. Combining both gives you surgical precision.

Protect your bootstrapping tasks. Anything that loads variables, gathers facts, or validates prerequisites gets tags: always. No exceptions.

Use never for dangerous operations. Migrations, resets, and data-destructive tasks should never run by accident. The never tag enforces this at the Ansible level, not just by convention.

Test your tags like code. Use --list-tasks --tags to validate that the right tasks are selected. Automate this check in CI.

Document your tag taxonomy. A tag list in your README is not optional. It's the API documentation for your playbooks.

Tags are one of those Ansible features where the investment in getting them right early pays dividends for the lifetime of your codebase. Your future self — and your team — will thank you when they can push a config change in 30 seconds instead of waiting through a 20-minute full run.

Share:

Was this article helpful?

Asif Muzammil
Asif Muzammil

Senior Cloud Architect

AWS, GCP, and Azure — I've built production workloads on all three. From landing zone design to multi-region failover, I architect cloud infrastructure that scales without surprises. Well-Architected Framework isn't a checklist, it's a mindset.

Related Articles

More in Ansible

View all →

Discussion