Introduction

Ansible-lint rule 504 (deprecated-local-action) flags the use of local_action in playbooks. This syntax is deprecated in favor of delegate_to: localhost, which is clearer, more consistent, and better supported by modern Ansible tooling.

The Error

$ ansible-lint playbook.yml
WARNING  Listing 1 violation(s) that are fatal
deprecated-local-action: Do not use 'local_action', use 'delegate_to: localhost'.
playbook.yml:5 Task/Handler: Send notification

                   Rule Violation Summary                   
 count tag                     profile rule associated tags 
     1 deprecated-local-action basic   deprecations         

Failed: 1 failure(s), 0 warning(s) on 1 files.

Root Cause

The local_action keyword was an early Ansible shorthand for running tasks on the controller instead of remote hosts. It has been superseded by delegate_to: localhost which:

  • Uses standard task syntax (no special keyword)
  • Supports all task attributes consistently
  • Is more readable and explicit
  • Works properly with become, register, and other directives

Problematic Code

---
- name: Deploy and notify
  hosts: webservers
  tasks:
    # DEPRECATED: local_action syntax
    - name: Send Slack notification
      local_action:
        module: ansible.builtin.uri
        url: https://hooks.slack.com/services/XXX/YYY/ZZZ
        method: POST
        body_format: json
        body:
          text: "Deployment started on {{ inventory_hostname }}"

    # DEPRECATED: single-line local_action
    - name: Wait for port
      local_action: ansible.builtin.wait_for host={{ inventory_hostname }} port=80 delay=5

Correct Code

Replace with delegate_to: localhost

---
- name: Deploy and notify
  hosts: webservers
  tasks:
    # CORRECT: delegate_to syntax
    - name: Send Slack notification
      ansible.builtin.uri:
        url: https://hooks.slack.com/services/XXX/YYY/ZZZ
        method: POST
        body_format: json
        body:
          text: "Deployment started on {{ inventory_hostname }}"
      delegate_to: localhost

    # CORRECT: standard task with delegate_to
    - name: Wait for port
      ansible.builtin.wait_for:
        host: "{{ inventory_hostname }}"
        port: 80
        delay: 5
      delegate_to: localhost

Common Use Cases for delegate_to: localhost

Health Checks Before Deployment

---
- name: Rolling deployment with health checks
  hosts: webservers
  serial: 2
  tasks:
    - name: Remove from load balancer
      ansible.builtin.uri:
        url: "https://lb.example.com/api/pool/remove"
        method: POST
        body_format: json
        body:
          server: "{{ inventory_hostname }}"
        headers:
          Authorization: "Bearer {{ lb_token }}"
      delegate_to: localhost

    - name: Deploy new version
      ansible.builtin.copy:
        src: app-v2.jar
        dest: /opt/app/app.jar
      notify: Restart app

    - name: Wait for app to start
      ansible.builtin.wait_for:
        host: "{{ inventory_hostname }}"
        port: 8080
        delay: 5
        timeout: 60
      delegate_to: localhost

    - name: Add back to load balancer
      ansible.builtin.uri:
        url: "https://lb.example.com/api/pool/add"
        method: POST
        body_format: json
        body:
          server: "{{ inventory_hostname }}"
        headers:
          Authorization: "Bearer {{ lb_token }}"
      delegate_to: localhost

  handlers:
    - name: Restart app
      ansible.builtin.systemd:
        name: myapp
        state: restarted

API Calls from Controller

---
- name: Create DNS records for new servers
  hosts: newservers
  tasks:
    - name: Create A record in Cloudflare
      ansible.builtin.uri:
        url: "https://api.cloudflare.com/client/v4/zones/{{ zone_id }}/dns_records"
        method: POST
        body_format: json
        headers:
          Authorization: "Bearer {{ cf_token }}"
        body:
          type: A
          name: "{{ inventory_hostname }}"
          content: "{{ ansible_host }}"
          ttl: 300
      delegate_to: localhost
      register: dns_result

    - name: Show DNS record ID
      ansible.builtin.debug:
        msg: "Created record: {{ dns_result.json.result.id }}"

Local File Operations

---
- name: Generate reports locally
  hosts: all
  tasks:
    - name: Gather disk usage
      ansible.builtin.command: df -h /
      register: disk_info
      changed_when: false

    - name: Write report to controller
      ansible.builtin.lineinfile:
        path: /tmp/disk-report.csv
        line: "{{ inventory_hostname }},{{ disk_info.stdout_lines[1] }}"
        create: true
        mode: '0644'
      delegate_to: localhost

delegate_to vs connection: local

There's another way to run tasks locally — connection: local at the play level:

---
# Entire play runs on localhost
- name: Local-only tasks
  hosts: localhost
  connection: local
  tasks:
    - name: This runs on the controller
      ansible.builtin.debug:
        msg: "Running locally"

When to use which:

MethodUse Case
delegate_to: localhostOne task needs to run locally in a remote play
hosts: localhost + connection: localEntire play targets the controller
local_actionNever (deprecated)

Important Notes

delegate_to and become

When using delegate_to: localhost, become still applies but targets the local machine:

- name: Write to protected local path
  ansible.builtin.copy:
    content: "{{ inventory_hostname }}"
    dest: /etc/ansible/facts.d/{{ inventory_hostname }}.fact
    mode: '0644'
  delegate_to: localhost
  become: true  # Escalates on localhost, not the remote host

delegate_to and Facts

Variables and facts still reference the original host, not localhost:

- name: Access remote host facts from local task
  ansible.builtin.debug:
    msg: "Remote host {{ inventory_hostname }} has IP {{ ansible_default_ipv4.address }}"
  delegate_to: localhost
  # ^ Still uses the remote host's facts

run_once with delegate_to

For tasks that should only execute once (not per-host):

- name: Send deployment notification once
  ansible.builtin.uri:
    url: https://hooks.slack.com/services/XXX
    method: POST
    body_format: json
    body:
      text: "Deploying to {{ ansible_play_hosts | length }} servers"
  delegate_to: localhost
  run_once: true

Migration Script

Quickly find all local_action usage in your project:

# Find all files using local_action
grep -rn "local_action" --include="*.yml" --include="*.yaml" .

# Count occurrences
grep -rc "local_action" --include="*.yml" --include="*.yaml" . | grep -v ":0$"

Conclusion

Replace all local_action with delegate_to: localhost. The modern syntax is clearer, works consistently with all task attributes (become, register, when), and satisfies ansible-lint rule 504. Use delegate_to: localhost + run_once: true for tasks that should execute exactly once from the controller during a multi-host play.