> ## Content Index
> Fetch the complete content index at: https://blog.coopnet.us/llms.txt
> Use this file to discover other available public pages before exploring further.

# Semaphore's per-task host limit does nothing — and takes the whole fleet with it
- URL: https://blog.coopnet.us/semaphore-task-limit-does-nothing/
- Published: 2026-07-17T06:00:00.000Z
- Updated: 2026-10-18T02:30:46.000Z
- Description: You set a task limit to test a playbook on one host. Semaphore ignores it and runs against every host in the inventory. Here is the trap, and the two ways to scope a run that actually work.
- Author: Renstur

The sensible way to roll out a risky playbook is to run it against **one** host first, confirm it behaves, then widen. [Semaphore](https://github.com/semaphoreui/semaphore?ref=blog.coopnet.us) 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:

```yaml
- 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:

```ini
[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.