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

# Run Ansible Network Playbooks from Semaphore UI: Templates, Vault Keys and Schedules
- URL: https://www.pinglabz.com/ansible-semaphore-ui-network-playbooks/
- Published: 2026-11-01T04:54:47.000Z
- Updated: 2026-11-01T05:02:01.000Z
- Description: Semaphore UI as a single binary on the control node, pointed at the Git repo from part 24: a project, a vault key, two templates and a schedule. Real runs against six IOS XE devices, including a vault key that was silently ignored.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

Everything in this series so far has been run from a terminal or a Git hook. That's fine for the people who wrote the playbooks. It isn't fine for the person on the NOC shift who needs to rerun the SNMP standard on one site at 3 AM without SSH access to the control node.

That's what AWX and Semaphore UI are for: a web front end with stored credentials, job templates, run history and schedules. AWX is the bigger option, but current releases need Kubernetes. Semaphore UI is one Go binary with a SQLite database, so that's what I tried.

I ran it on the control node in my home lab against the same six IOS XE devices and the Git repo from [part 24](https://www.pinglabz.com/ansible-git-pipeline-ci/). Every run below is real, including three failed runs. One caveat up front: I drove Semaphore through its REST API, the same API its web pages call, because that gave me clean text logs to copy. I don't have screenshots of the UI in this post; the task logs are the same text the UI shows on a task's page.

Versions: Semaphore UI 2.19.17, ansible-core 2.21, cisco.ios 11.6, IOS XE 17.18.2.

[![Lab diagram: a NOC user opens the Semaphore web UI, a single binary with SQLite listening on 127.0.0.1:3000 on the control node. A project holds four items: the Git repository on the main branch, an inventory file inside the repo, a key store that keeps only the encrypted vault password (device logins come from vaulted inventory variables), and an environment that sets ANSIBLE_CONFIG and ANSIBLE_COLLECTIONS_PATH. They feed two templates: the SNMP location and contact run, and an SNMP check dry run using --check --diff, whose test schedule fired every minute and was then deleted. Both templates run ansible-playbook against six devices, five routers and one access switch. A dotted arrow from the CI gate built in part 24 notes that CI decides what is in main and Semaphore decides who runs it and when. An orange callout explains that the repo ansible.cfg vault_password_file silently overrode the stored key, so task 2 succeeded with a wrong key, fixed with a Semaphore-only ANSIBLE_CONFIG. A footer lists seven task runs: 1 error, collections not found; 2 success, wrong key ignored; 3 error, vault decryption failed; 4 error, key update needed override_secret; 5, 6 and 7 success, the last two from the schedule.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-14.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-14.png?ref=pinglabz.com)

Semaphore on the control node runs two templates from the CI-gated repo. The dry-run schedule was a test that fired every minute and was then removed.

## Install: one binary

```bash
mkdir -p ~/semaphore && cd ~/semaphore
curl -sSL -o s.tgz https://github.com/semaphoreui/semaphore/releases/download/v2.19.17/semaphore_2.19.17_linux_amd64.tar.gz
tar xzf s.tgz semaphore && rm s.tgz
./semaphore version

```

```text
2.19.17-f8cefe6-1792391818

```

The config file, with the three random keys replaced by placeholders:

```json
{
  "dialect": "sqlite",
  "sqlite": {"host": "~/semaphore/db/semaphore.sqlite"},
  "tmp_path": "~/semaphore/tmp",
  "address": "127.0.0.1",
  "port": ":3000",
  "cookie_hash": "<32 random bytes, base64>",
  "cookie_encryption": "<32 random bytes, base64>",
  "access_key_encryption": "<32 random bytes, base64>",
  "max_parallel_tasks": 2
}

```

My first attempt used `"dialect": "bolt"`, which older guides still show. 2.19 refuses to start with it:

```text
panic: value of field 'Dialect' is not valid: bolt (Must match regex: '^mysql|postgres|sqlite$')

```

`access_key_encryption` matters: it's the key Semaphore uses to encrypt the stored credentials in the database. Lose it and every saved secret is unreadable.

Then create the database, an admin user and start the server. The admin password comes from a file so it never appears on a command line or in this post:

```bash
./semaphore migrate --config config.json
./semaphore user add --admin --login admin --name Admin \
  --email admin@example.net --password "$(cat .admin-pass)" --config config.json
. ~/ansible-venv/bin/activate
nohup env PATH="$PATH" ./semaphore server --config config.json > server.log 2>&1 &

```

Starting it from the activated venv is the whole Ansible integration: Semaphore runs whatever `ansible-playbook` is first on its PATH. It listens on 127.0.0.1 only; for anyone else to use it you'd put a reverse proxy with TLS in front.

## The project: repo, inventory, key, environment, template

Semaphore's model maps cleanly onto what we already have. A **repository** is the Git repo. An **inventory** is a file inside it. A **key** is a stored credential. An **environment** is a set of environment variables. A **template** ties them to one playbook.

These are the calls I made, from a small Python helper that logs in with the admin password file and keeps the session cookie:

```python
P = call("POST", "/projects", {"name": "netops"})["id"]
call("POST", f"/project/{P}/keys", {"name": "none", "type": "none", "project_id": P})
call("POST", f"/project/{P}/keys", {"name": "ansible-vault", "type": "login_password",
     "project_id": P, "login_password": {"login": "", "password": "wrong-on-purpose"}})
call("POST", f"/project/{P}/repositories", {"name": "netops", "project_id": P,
     "git_url": "file://~/git/netops.git", "git_branch": "main", "ssh_key_id": none_id})
call("POST", f"/project/{P}/inventory", {"name": "sites", "project_id": P, "type": "file",
     "inventory": "inventory/hosts.yml", "ssh_key_id": none_id, "repository_id": rid})
call("POST", f"/project/{P}/environment", {"name": "lab", "project_id": P, "json": "{}",
     "env": json.dumps({"ANSIBLE_FORCE_COLOR": "0"})})
call("POST", f"/project/{P}/templates", {"name": "SNMP location and contact", "project_id": P,
     "app": "ansible", "playbook": "playbooks/snmp-location.yml", "inventory_id": iid,
     "repository_id": rid, "environment_id": eid,
     "vaults": [{"vault_key_id": vid, "type": "password"}]})

```

In the web UI each of these is its own page with a single form; the API calls are the same thing without the clicking.

The device login is not a Semaphore key. The SSH key is type `none` because `network_cli` logs in with the username and password from the inventory's vaulted `group_vars`. The only secret Semaphore holds is the vault password. I started it with a deliberately wrong value to see what a failure looks like.

## Failed run 1: the collections weren't there

```text
Task SNMP location and contact added to queue
Started task #1 of template 'SNMP location and contact'
Preparing: 1
Cloning Repository file://~/git/netops.git
Cloning into 'repository_1_template_1'...
Get current commit hash
Get current commit message
cloning inventory repository
Cloning Repository file://~/git/netops.git
Cloning into 'repository_1_template_1_inventory_1'...
[WARNING]: Error getting vault password file (default): The vault password file provided '~/semaphore/tmp/project_1/repository_1_template_1' can not be a directory
[ERROR]: couldn't resolve module/action 'cisco.ios.ios_command'. This often indicates a misspelling, missing collection, or incorrect module path.
Origin: ~/semaphore/tmp/project_1/repository_1_template_1/playbooks/snmp-location.yml:20:7
18
19   handlers:
20     - name: Save config
         ^ column 7
Failed to run task: exit status 4

```

Two problems in one log. The empty `ANSIBLE_VAULT_PASSWORD_FILE` I'd set in an early version of the environment turned into a directory path; I removed it. The real error is the second one: Semaphore's task didn't find `cisco.ios`, even though the same `ansible-playbook` finds it from my shell. The fix was to set it in the environment explicitly:

```json
{"ANSIBLE_COLLECTIONS_PATH": "~/.ansible/collections", "ANSIBLE_FORCE_COLOR": "0"}

```

A cleaner long-term fix is a `collections/requirements.yml` in the repo. Semaphore looks for one before every run (the log lines I trimmed above are it checking four locations) and installs it if found.

## The run that shouldn't have worked

The next run succeeded, all six devices `ok`. That was wrong: the vault key in Semaphore still held `wrong-on-purpose`.

The reason is the repo's `ansible.cfg` from part 24, which has `vault_password_file` pointing at a file on the control node. Semaphore runs the playbook inside its clone of the repo, so Ansible read that config, found the real password file and ignored the wrong one Semaphore passed in. Ansible tries every vault secret it's given, so one right answer is enough.

That matters for a real setup. If Semaphore runs on the same box as your CLI work, it can quietly use credentials you never put into it, and taking the key out of Semaphore wouldn't stop anything. I pointed Semaphore at its own config without the vault line, by adding `ANSIBLE_CONFIG` to the environment:

```ini
[defaults]
inventory = inventory/hosts.yml
host_key_checking = True
gathering = explicit
interpreter_python = auto_silent

[persistent_connection]
command_timeout = 30
connect_timeout = 30

```

## Failed run 2: the vault key

Now the wrong key fails the way it should:

```text
Task SNMP location and contact added to queue
Started task #3 of template 'SNMP location and contact'
Preparing: 3
Updating Repository file://~/git/netops.git
From file://~/git/netops
 * branch            main       -> FETCH_HEAD
Already up to date.
Get current commit hash
Get current commit message
cloning inventory repository
Updating Repository file://~/git/netops.git
From file://~/git/netops
 * branch            main       -> FETCH_HEAD
Already up to date.
PLAY [SNMP location and contact from Git] **************************************
[ERROR]: Decryption failed (no vault secrets were found that could decrypt).
Failed to run task: exit status 4

```

This is the failed-run view a NOC user would see: the task goes red in the history, and the log ends with the Ansible error and the exit code. Nothing reached the devices, because decryption happens before the first task.

Then I put the real vault password into the key. Updating a key through the API needs `"override_secret": true`; without it the call returns success and keeps the old secret, which cost me one more failed run (task 4, same error).

## A good run

```text
Task SNMP location and contact added to queue
Started task #5 of template 'SNMP location and contact'
Preparing: 5
Updating Repository file://~/git/netops.git
From file://~/git/netops
 * branch            main       -> FETCH_HEAD
Already up to date.
Get current commit hash
Get current commit message
cloning inventory repository
Updating Repository file://~/git/netops.git
From file://~/git/netops
 * branch            main       -> FETCH_HEAD
Already up to date.
PLAY [SNMP location and contact from Git] **************************************
TASK [Set location and contact] ************************************************
...
ok: [hq-core2]
ok: [br1-acc1]
...
TASK [Show the lines that would be sent] ***************************************
...
skipping: [hq-core2]
...
skipping: [br1-acc1]
...
PLAY RECAP *********************************************************************
br1-acc1                   : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
...
hq-core2                   : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
...

```

Twelve seconds, every device `ok`, nothing changed, because the devices already match the repo. Semaphore records the commit it ran (8c11ea1, the last commit from part 24) next to each task, so the history answers "which version of the playbook ran at 3 AM?".

## A schedule

I added a second template with `--check --diff` as its arguments, a dry-run version a NOC user can run any time without risk, and gave it a schedule with cron format `* * * * *`:

```python
call("POST", "/project/1/templates", {"name": "SNMP check (dry run)", ..., 
     "arguments": json.dumps(["--check", "--diff"])})
call("POST", "/project/1/schedules", {"project_id": 1, "template_id": 2,
     "cron_format": "* * * * *", "name": "every minute (test)", "active": True})

```

It fired twice before I deleted it. The second scheduled run:

```text
Task SNMP check (dry run) added to queue
Started task #7 of template 'SNMP check (dry run)'
Preparing: 7
Updating Repository file://~/git/netops.git
From file://~/git/netops
 * branch            main       -> FETCH_HEAD
Already up to date.
Get current commit hash
Get current commit message
cloning inventory repository
Updating Repository file://~/git/netops.git
From file://~/git/netops
 * branch            main       -> FETCH_HEAD
Already up to date.
PLAY [SNMP location and contact from Git] **************************************
TASK [Set location and contact] ************************************************
...
ok: [hq-core2]
ok: [br1-acc1]
...
TASK [Show the lines that would be sent] ***************************************
...
skipping: [hq-core2]
...
skipping: [br1-acc1]
...
PLAY RECAP *********************************************************************
br1-acc1                   : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
...
hq-core2                   : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
...

```

The task history for the project at the end, summarised from the API's task list (times converted to PDT; the API leaves the user empty for scheduled runs):

```text
task  template              status   start     commit   user
1     SNMP location         error    9:49:42   8c11ea1  admin
2     SNMP location         success  9:50:04   8c11ea1  admin   (wrong key ignored)
3     SNMP location         error    9:50:31   8c11ea1  admin
4     SNMP location         error    9:50:50   8c11ea1  admin
5     SNMP location         success  9:51:08   8c11ea1  admin
6     SNMP check (dry run)  success  9:52:01   8c11ea1  (schedule)
7     SNMP check (dry run)  success  9:53:01   8c11ea1  (schedule)

```

For a real deployment you'd schedule the dry-run template nightly and alert on a non-`ok` result, which is part 22's audit with a UI on top.

## Semaphore or the Git hook?

They do different jobs. The part 24 hook decides what is allowed into `main`. Semaphore decides who can run what's in `main`, when, and keeps the log. In practice you want both: Semaphore's repository points at the branch the CI gate protects, so the UI can only ever run reviewed code.

What I didn't test: users and roles (I only used the admin), LDAP or OIDC login, and running Semaphore under systemd with TLS. Those are the next steps before anyone else uses it.

## Cleanup

This was a trial, so I removed it. I stopped the server, deleted `~/semaphore` (binary, SQLite database with the encrypted key, temp clones) and checked that nothing is listening on port 3000 and no cron entry exists. The schedule was deleted before that. The Git repo from part 24 is untouched.

That's the end of the planned topics for this series. The full list is on the series page.