Semaphore's per-task host limit does nothing — and takes the whole fleet with it


The sensible way to roll out a risky playbook is to run it against one host first, confirm it behaves, then widen. Semaphore has a per-task limit field for exactly this, and its API accepts a limit on task creation. So I set the limit to a single host, launched the run, and watched it start configuring every host in the inventory.

If the playbook is touching sshd or the firewall, “oops, that was the whole fleet” is not a great place to be.

The trap

In the Semaphore build I was on, the task-level limit is cosmetic. Create a task with limit: host-a and:

  • the API happily accepts it and echoes "limit": "host-a" back,
  • the task detail shows it,
  • and ansible-playbook runs with no --limit at all — against the full inventory.

My first instinct was that the template just needed to allow CLI overrides, so I set allow_override_args_in_task: true and tried again. Same result: fleet-wide. The limit field is simply never handed to Ansible.

And note what does not save you here: serial. serial: 1 bounds how many hosts run at once, not which hosts run. A serialized run against the wrong scope is still a run against the wrong scope — just one host at a time.

What actually scopes a run

Two approaches work reliably. The first is the one I’d reach for every time.

1. A required target, enforced by the playbook

Make the playbook refuse to run without an explicit target, and expose that target as a required survey variable so the run dialog forces you to fill it in:

- name: Baseline
  hosts: "{{ target | mandatory }}"
  become: true
  # ...

Then add a survey variable named target, marked required. Now every run prompts for a host or group, all included, and a blank value fails closed instead of defaulting to the whole fleet. The scope lives in the playbook and the prompt — not in a field that might quietly be ignored.

This is also just good hygiene: the same playbook run from the CLI needs -e target=host-a, so “what does this run against” is always explicit and never inferred.

2. A one-host inventory

When you cannot touch the playbook, build a throwaway static inventory containing only the target host, point the template at it for the test, and switch back afterward:

[all]
host-a ansible_host=10.0.0.10

[all:vars]
ansible_user=root

It is guaranteed — an inventory of one host physically cannot run against two — but it is manual, and you have to remember to revert the template’s inventory when you are done. Good for a one-off; the survey variable is better as a standing pattern.

The takeaway

Do not trust a “limit” box to constrain blast radius, especially for playbooks that change security-relevant state. Verify it actually passes --limit on your version — and if you cannot, make scope a required input of the playbook itself, so an empty or ignored field fails safe instead of fanning out across everything you own.