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:
| Method | Use Case |
|---|---|
delegate_to: localhost | One task needs to run locally in a remote play |
hosts: localhost + connection: local | Entire play targets the controller |
local_action | Never (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$"
Related Articles
- Ansible delegate_to — Full delegation guide
- Ansible run_once — Single execution patterns
- Ansible-lint Guide — Complete linting configuration
- Ansible Error 205: playbook-extension — File extension rule
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.