Introduction

run_once: true tells Ansible to execute a task on only one host in the play, even when the play targets multiple hosts. It's essential for one-time operations like database migrations, load balancer updates, or sending a single notification — tasks that should happen exactly once, not once per host.

Basic Syntax

- name: Run database migration (once only)
  ansible.builtin.command: /opt/app/migrate.sh
  run_once: true

By default, Ansible runs the task on the first host in the inventory for that play. All other hosts skip it. The registered variable is still available on all hosts.

How run_once Works

---
- name: Deploy application
  hosts: webservers  # web-01, web-02, web-03
  tasks:
    - name: This runs on ALL hosts
      ansible.builtin.debug:
        msg: "Running on {{ inventory_hostname }}"
    # Output: web-01, web-02, web-03

    - name: This runs on FIRST host only
      ansible.builtin.debug:
        msg: "Running on {{ inventory_hostname }}"
      run_once: true
    # Output: web-01 only

    - name: But the result is available everywhere
      ansible.builtin.command: cat /opt/app/version.txt
      run_once: true
      register: app_version

    - name: All hosts can use the registered variable
      ansible.builtin.debug:
        msg: "Version is {{ app_version.stdout }}"
    # Output: all three hosts print the version

run_once with delegate_to

Combine run_once with delegate_to to run the task on a specific host:

---
- name: Deploy with database migration
  hosts: webservers
  tasks:
    - name: Run migration on the database server (not a webserver)
      ansible.builtin.command: /opt/app/migrate.sh
      run_once: true
      delegate_to: "{{ groups['dbservers'][0] }}"

    - name: Update load balancer (once, on the LB)
      ansible.builtin.uri:
        url: "https://{{ groups['loadbalancers'][0] }}/api/backends"
        method: POST
        body_format: json
        body:
          servers: "{{ groups['webservers'] }}"
      run_once: true
      delegate_to: localhost

    - name: Send Slack notification (once, from control node)
      ansible.builtin.uri:
        url: "{{ slack_webhook }}"
        method: POST
        body_format: json
        body:
          text: "Deployment complete to {{ ansible_play_hosts | length }} servers"
      run_once: true
      delegate_to: localhost

⚠️ run_once with strategy: free

This is the most common pitfall. The warning message:

[WARNING]: Using run_once may behave differently if strategy is set to free

With strategy: linear (default), all hosts reach each task together, so run_once reliably executes on the first host and skips the rest.

With strategy: free, hosts run independently at their own pace. The first host to reach a run_once task executes it — but other hosts might reach it before the first host finishes, causing unpredictable behavior.

---
# SAFE — linear strategy (default)
- name: Deploy app
  hosts: webservers
  strategy: linear
  tasks:
    - name: Migrate database
      ansible.builtin.command: /opt/app/migrate.sh
      run_once: true  # Predictable — runs on first host

---
# RISKY — free strategy
- name: Deploy app
  hosts: webservers
  strategy: free
  tasks:
    - name: Migrate database
      ansible.builtin.command: /opt/app/migrate.sh
      run_once: true  # Which host runs first? Unpredictable!

Fix: If you need strategy: free with one-time tasks, move the run_once task to a separate play with strategy: linear:

---
- name: Pre-deployment (linear, safe for run_once)
  hosts: webservers
  strategy: linear
  tasks:
    - name: Migrate database
      ansible.builtin.command: /opt/app/migrate.sh
      run_once: true

- name: Deploy application (free, parallel)
  hosts: webservers
  strategy: free
  tasks:
    - name: Pull latest code
      ansible.builtin.git:
        repo: "{{ app_repo }}"
        dest: /opt/app

run_once with serial

When using serial (rolling updates), run_once executes once per batch, not once for the entire play:

---
- name: Rolling update
  hosts: webservers  # 6 hosts
  serial: 2          # 3 batches of 2

  tasks:
    - name: This runs 3 times (once per batch)!
      ansible.builtin.debug:
        msg: "Batch task on {{ inventory_hostname }}"
      run_once: true
    # Runs on: web-01 (batch 1), web-03 (batch 2), web-05 (batch 3)

If you truly need once for the entire play, use a separate play:

---
- name: One-time setup
  hosts: webservers[0]  # Target only first host
  tasks:
    - name: Runs exactly once
      ansible.builtin.command: /opt/app/setup.sh

- name: Rolling deploy
  hosts: webservers
  serial: 2
  tasks:
    - name: Deploy application
      ansible.builtin.copy:
        src: app/
        dest: /opt/app/

Common Patterns

Database Migration Before Deploy

---
- name: Deploy with migration
  hosts: webservers
  tasks:
    - name: Stop application
      ansible.builtin.service:
        name: myapp
        state: stopped

    - name: Run database migration
      ansible.builtin.command:
        cmd: python manage.py migrate --noinput
        chdir: /opt/app
      run_once: true
      register: migration

    - name: Show migration output
      ansible.builtin.debug:
        msg: "{{ migration.stdout_lines }}"
      run_once: true

    - name: Deploy new code
      ansible.builtin.copy:
        src: app/
        dest: /opt/app/

    - name: Start application
      ansible.builtin.service:
        name: myapp
        state: started

Collect Facts Once, Use Everywhere

- name: Get cluster version from any node
  ansible.builtin.command: kubectl version --short
  run_once: true
  register: k8s_version
  delegate_to: "{{ groups['k8s_masters'][0] }}"

- name: All hosts know the cluster version
  ansible.builtin.debug:
    msg: "K8s: {{ k8s_version.stdout }}"

Create Shared Resource Once

- name: Create shared NFS export
  ansible.builtin.file:
    path: /exports/shared
    state: directory
    mode: "0755"
  run_once: true
  delegate_to: "{{ groups['nfs_server'][0] }}"

- name: Mount the share on all hosts
  ansible.posix.mount:
    path: /mnt/shared
    src: "{{ groups['nfs_server'][0] }}:/exports/shared"
    fstype: nfs
    state: mounted

Send Notification Once

- name: Notify deployment complete
  community.general.slack:
    token: "{{ slack_token }}"
    channel: "#deployments"
    msg: "v{{ app_version }} deployed to {{ ansible_play_hosts | length }} hosts"
  run_once: true
  delegate_to: localhost

run_once vs hosts: group[0]

Two approaches to run something once:

# Approach 1: run_once (task level)
- hosts: webservers
  tasks:
    - name: Migrate DB
      command: /opt/migrate.sh
      run_once: true

# Approach 2: target single host (play level)
- hosts: webservers[0]
  tasks:
    - name: Migrate DB
      command: /opt/migrate.sh
Featurerun_once: truehosts: group[0]
Variable sharing✅ Result available on all hosts❌ Only on targeted host
Mixed with per-host tasks✅ Same play❌ Needs separate play
serial behavior⚠️ Once per batch✅ Truly once
strategy: free⚠️ Unpredictable✅ Safe

Troubleshooting

Task Runs Multiple Times with serial

Expected behavior — run_once runs once per batch. Use a separate play targeting a single host if you need truly once.

"Variable is undefined" on Other Hosts

If the run_once task fails or is skipped due to a when condition, the registered variable won't be set on any host. Add a default:

- name: Use result with fallback
  ansible.builtin.debug:
    msg: "{{ my_result.stdout | default('not available') }}"

Conclusion

run_once: true executes a task on one host while making the result available to all hosts. Use it for database migrations, notifications, shared resource creation, and any task that should happen exactly once. Key gotchas: it runs once per batch with serial, and behaves unpredictably with strategy: free. For truly single-execution tasks in rolling updates, target hosts: group[0] in a separate play instead.