GuidesDevelopers and hostingAnsible with Semaphore UI

Run Ansible playbooks from a web UI with Semaphore and rootless Podman

Semaphore UI on Debian 13, a web UI where a small team runs Ansible playbooks from Git on its servers, with schedules, read-only teammates and an API, in rootless Podman with PostgreSQL behind Caddy.

Tested on Semaphore UI 2.19.16 on Debian 13 (trixie) on a Melonslab server Updated October 14, 2026

Recommended server for this guide

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy · 2 vCPU · 8 GB Memory · 60 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€8.99/mo

Deploy now
On this page

What you will set up

Semaphore UI is a web interface for running Ansible playbooks. Your playbooks stay in Git; Semaphore keeps the SSH keys, the lists of servers and the variables, and anyone on the team can start a playbook with a button, follow its output live and look back at every run. It can also run playbooks on a schedule, ask for values before a run, send an alert when a run fails, and start runs from a script through its API. It runs Terraform, OpenTofu and shell scripts in the same way.

Semaphore UI started in 2014 as Ansible Semaphore, and is developed today by Denis Gukov and the Semaphore UI team, which gives Serbia as its home on GitHub. The source is open under the MIT licence. On 14 October 2026, Semaphore's pricing page listed a paid Pro edition, from $20 a month, with runners per project, running tasks in Docker containers, a Terraform state backend, secrets from HashiCorp Vault and OpenBao, LDAP and single sign-on, and two-factor login; and an Enterprise edition, priced on request, with custom roles, Kubernetes and high availability. Two-factor login worked in the free version we tested, as step 6 shows. The project publishes two images: semaphoreui/semaphore-community, built from the open source alone, and semaphoreui/semaphore, which also contains the closed Pro code and waits for a licence key. This guide uses the community image. In our tests it made no outside connections of its own.

Here Semaphore runs as a pod under a user of its own called semaphore, with PostgreSQL for its data, behind Caddy from the Podman guide. Semaphore supports PostgreSQL, MySQL (or MariaDB) and SQLite; BoltDB, its old default, is no longer supported since version 2.19.

Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:

  • Semaphore UI 2.19.16 started with PostgreSQL 18, and the first admin was created on the command line before Caddy was set up. There is no sign-up page.
  • A playbook in a Git repository on the server ran against the server itself, over SSH, as an ordinary user. A question asked before the run reached the playbook, and the output showed each step.
  • A schedule ran the playbook at the time given, in Swedish time.
  • A teammate with the Guest role could read the runs and their output, but not start a run or change anything.
  • Two-factor login with a time-based one-time code worked for a user.
  • A failed run sent an email alert, caught by a test mail server on the server itself.
  • A run was started with curl and an API token.
  • A capture of the server's traffic showed no outside connections while Semaphore started, ran playbooks and served the web interface.
  • An update from 2.19.15 to 2.19.16 went through by changing the image tag. Semaphore was restored from a backup into a new, empty install, and everything came back by itself after a reboot.

The pod used about 45 MB of memory when idle and about 160 MB while a playbook ran against one server.

Before you start

You need:

  • a server set up as in the Podman guide, with Caddy running;
  • an A record and an AAAA record for ops.example.com pointing at your server.

The examples use ops.example.com for Semaphore, anna for your own account, and deploy for the user that Semaphore logs in as on the servers it manages. Here the managed server is the same server, so you can try everything on one machine; step 8 shows how to add others. Replace the names throughout.

1. Create the user

As root:

useradd -m -s /bin/bash semaphore
loginctl enable-linger semaphore
machinectl shell semaphore@

Everything up to step 5 runs as semaphore.

2. Create the secrets

openssl rand -hex 24 | tr -d '\n' | podman secret create semaphore-db-password -
for s in access-key-encryption cookie-hash cookie-encryption; do
  openssl rand -base64 32 | tr -d '\n' | podman secret create semaphore-$s -
done

The first line makes the database password. The loop makes three keys: access-key-encryption encrypts the SSH keys and passwords you store in Semaphore, and the two cookie keys sign and encrypt logins. Without the first key, a restored database has keys nobody can read, so step 15 saves a copy of all three. Podman stores them, not the files below.

3. Define the pod

mkdir -p ~/.config/containers/systemd ~/repos
cd ~/.config/containers/systemd

Create semaphore.pod:

[Pod]
PodName=semaphore
# Semaphore's user in the container (1001) is the semaphore user on the server.
UserNS=keep-id:uid=1001,gid=1001
# Only Caddy, on this server, can reach Semaphore: the port is not open to the internet.
PublishPort=127.0.0.1:8183:3000

[Install]
WantedBy=default.target

Create semaphore-db.container:

[Container]
ContainerName=semaphore-db
Image=docker.io/library/postgres:18-alpine
Pod=semaphore.pod
Volume=semaphore-db:/var/lib/postgresql
Environment=POSTGRES_USER=semaphore POSTGRES_DB=semaphore
Secret=semaphore-db-password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U semaphore
HealthInterval=10s
Notify=healthy
AutoUpdate=registry

[Service]
Restart=always

And semaphore-app.container:

[Unit]
Requires=semaphore-db.service
After=semaphore-db.service

[Container]
ContainerName=semaphore-app
Image=docker.io/semaphoreui/semaphore-community:v2.19.16
Pod=semaphore.pod
Exec=/usr/local/bin/semaphore server --no-config
Volume=%h/repos:/repos:ro
Environment=SEMAPHORE_DB_DIALECT=postgres SEMAPHORE_DB_HOST=127.0.0.1:5432 SEMAPHORE_DB=semaphore SEMAPHORE_DB_USER=semaphore
Environment='SEMAPHORE_DB_OPTIONS={"sslmode":"disable"}'
Environment=SEMAPHORE_WEB_ROOT=https://ops.example.com
Environment=TZ=Europe/Stockholm SEMAPHORE_SCHEDULE_TIMEZONE=Europe/Stockholm
Environment=SEMAPHORE_TOTP_ENABLED=true
Secret=semaphore-db-password,type=env,target=SEMAPHORE_DB_PASS
Secret=semaphore-access-key-encryption,type=env,target=SEMAPHORE_ACCESS_KEY_ENCRYPTION
Secret=semaphore-cookie-hash,type=env,target=SEMAPHORE_COOKIE_HASH
Secret=semaphore-cookie-encryption,type=env,target=SEMAPHORE_COOKIE_ENCRYPTION

[Service]
Restart=always

What the settings do:

  • UserNS=keep-id makes Semaphore's user inside the container the same as semaphore on the server, so Semaphore can read the Git repositories you keep in ~/repos, which it sees as /repos. Without it, Git refuses them because they belong to someone else. PostgreSQL in the same pod runs as that user too.
  • Exec=... --no-config starts Semaphore with its settings from the lines above only. The image's own start script would instead write a config.json with the database password and keys of its own.
  • SEMAPHORE_DB_OPTIONS turns off TLS to the database, which PostgreSQL in the container does not offer. The connection never leaves the pod.
  • SEMAPHORE_WEB_ROOT is the public address. Semaphore uses it for every page and for the links in its alerts, and marks login cookies secure because it starts with https.
  • SEMAPHORE_SCHEDULE_TIMEZONE makes schedules run in Swedish time; Semaphore's default is UTC. TZ sets the time in the logs.
  • SEMAPHORE_TOTP_ENABLED lets users turn on two-factor login (step 6).
  • The image is pinned to a version. Semaphore maintains two lines at once, 2.18 and 2.19, and its latest tag pointed at 2.18.31 when we tested, so a version tag is the way to know what you run. Step 16 shows how to update. PostgreSQL stays on 18 and is updated by podman auto-update.

Start it:

systemctl --user daemon-reload
systemctl --user start semaphore-pod
systemctl --user enable --now podman-auto-update.timer

The first start downloads about 450 MB of images, which unpack to about 1.7 GB. The Semaphore image includes Ansible 13 (ansible-core 2.20), OpenTofu, Terraform, Git and Python.

4. Create the first admin

Semaphore has no setup page: the first account is made on the command line. Wait until Semaphore answers, which also means it has finished setting up its database:

until curl -sf -o /dev/null http://127.0.0.1:8183/; do sleep 2; done

Then create your account with a random password:

pw=$(openssl rand -base64 18)
podman exec semaphore-app semaphore user add --no-config --admin --login anna --name "Anna Andersson" --email anna@example.com --password "$pw"
echo "$pw"

It prints User anna <anna@example.com> added! and the password. Keep the password in your password manager. An SSH tunnel would not let you log in before Caddy is set up: every page tells the browser to load the rest from https://ops.example.com, which does not answer yet.

If you ever lose the password, set a new one the same way:

podman exec semaphore-app semaphore user change-by-login --no-config --login anna --password 'a-new-long-password'

5. Put Caddy in front

Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:

ops.example.com {
    reverse_proxy 127.0.0.1:8183
}

Restart Caddy with systemctl --user restart caddy, open https://ops.example.com, and sign in with anna and the password from step 4. The interface is translated into 15 languages, but not Swedish.

6. Check who can get in

Semaphore has no sign-up page and no public access. Only an admin can create accounts, under Users (step 11).

Semaphore does not limit login attempts and does not log failed ones: in our test, 30 wrong passwords in a row were all just refused, and the right one still worked. Use long, random passwords, and turn on two-factor login, which SEMAPHORE_TOTP_ENABLED from step 3 allows. Each user does it for their own account: open the menu with your name at the bottom left, Edit Account, the Security tab, and switch on Time-based one-time password. Scan the QR code with an authenticator app. From then on, Semaphore asks for a code after the password.

If a user loses their phone, switch it off for them, as semaphore:

podman exec semaphore-app semaphore user totp disable --no-config --login erik

7. Put a playbook in Git

Semaphore runs playbooks from a Git repository. If your playbooks are already on GitHub, GitLab or your own Forgejo, use that repository in step 8. Here you make one on the server, and a user for Semaphore to manage.

As root, install Git, create the deploy user, and make an SSH key for Semaphore that lets it log in as deploy:

apt install -y git
useradd -m -s /bin/bash deploy
ssh-keygen -t ed25519 -N "" -C semaphore -f /root/semaphore-key
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
install -m 600 -o deploy -g deploy /root/semaphore-key.pub /home/deploy/.ssh/authorized_keys

deploy gets no sudo, so the playbook below can only change its own files. On another server, you add the same public key, /root/semaphore-key.pub, to the user Semaphore should log in as there.

Then, as semaphore (machinectl shell semaphore@), create the repository and a working copy:

git config --global user.name "Anna Andersson"
git config --global user.email anna@example.com
git init --bare -b main ~/repos/ops.git
git clone ~/repos/ops.git ~/ops
cd ~/ops

Create ~/ops/site.yml, a playbook that shows the free disk space on each server and writes a note:

- name: Check the servers
  hosts: all
  tasks:
    - name: Check free disk space
      ansible.builtin.command: df -h /
      register: disk
      changed_when: false

    - name: Print it
      ansible.builtin.debug:
        msg: "{{ ansible_facts.hostname }}: {{ disk.stdout_lines[1] }}"

    - name: Write a note in the home directory
      ansible.builtin.copy:
        dest: "~/semaphore-note.txt"
        content: "{{ note }}\n"

Commit it and push:

git add site.yml
git commit -m "First playbook"
git push origin main

You change playbooks the same way: edit, commit and push. Semaphore fetches the latest commit before every run.

8. Set up the project

In Semaphore, choose New Project, give it a Project Name such as Servers, tick Allow alerts for this project, and Create. A project holds everything below. Its menu on the left has a page for each part.

Key Store > New Key: the SSH key Semaphore logs in with.

  • Key Name: deploy. Type: SSH Key. Username (Optional): deploy.
  • Private Key: paste the whole output of cat /root/semaphore-key, run as root, from -----BEGIN to -----END OPENSSH PRIVATE KEY-----.

Choose Create. Semaphore encrypts the key with access-key-encryption from step 2, and never shows it again, not even to admins. Now delete the copy on the server, as root: rm /root/semaphore-key.

Repositories > New Repository:

  • Name: ops. URL or path: file:///repos/ops.git. Branch / Tag: main. Access Key: None.

For a repository on GitHub or Forgejo, use its SSH address, such as git@github.com:example/ops.git, and an SSH key from the Key Store that has read access to it, such as a deploy key.

Inventory > New Inventory > Ansible Inventory, the list of servers:

  • Name: Servers. User Credentials: deploy. Type: Static.
  • The inventory itself:
[web]
host.containers.internal ansible_user=deploy ansible_python_interpreter=/usr/bin/python3

host.containers.internal is this server, as seen from inside the pod: there, the server's own public address leads back into the pod. Other servers go on lines of their own, by name or address, such as web2.example.com ansible_user=deploy. Ansible needs Python on each server, which Debian has.

Variable Groups > New Group: values that every run with this group gets.

  • Group Name: Production. Extra variables: {"note": "Managed by Semaphore"}.

Choose Create.

9. Run a playbook

Task Templates > New template > Ansible Playbook ties it together:

  • Name: Check servers. Path to playbook file: site.yml.
  • Inventory: Servers. Repository: ops. Variable Groups: Production.
  • Under Survey Variables, choose Add variable: Name note, Title Note to write. Semaphore will ask for it before each run, and the answer overrides the value from the variable group.

Choose Create, open the template, and choose Run. Type a note, such as Hello from Semaphore, and choose Run. The output appears line by line: Semaphore clones the repository, then Ansible connects and runs each task:

TASK [Print it] ****************************************************************
ok: [host.containers.internal] =>
    msg: 'web1: /dev/vda3        59G  4.7G   52G   9% /'

TASK [Write a note in the home directory] **************************************
changed: [host.containers.internal]

PLAY RECAP *********************************************************************
host.containers.internal   : ok=4    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

On the server, cat /home/deploy/semaphore-note.txt shows your note. History lists every run, with who started it, the commit it ran, and its full output.

The image turns off SSH host key checking, so Semaphore connects to any server that answers on the address in the inventory without checking that it is the right one. Keep the addresses in the inventory to servers and networks you trust.

10. Run it on a schedule

Schedule > New Schedule > Cron:

  • Name: Weekday mornings. Template: Check servers.
  • Cron: 0 6 * * 1-5, at 06:00 Monday to Friday.

Choose Create. Times follow SEMAPHORE_SCHEDULE_TIMEZONE from step 3: in our test, a schedule for 05:05 ran at 05:05 Swedish time. A scheduled run asks no questions, so the playbook gets the values from the variable group. Run once in the same menu runs a template once, at a date and time.

11. Add teammates

As an admin, open Users in the menu at the bottom left and choose New User: a Name, Username, Email and Password. Leave Admin user off: admins can see and change every project. Send the password to the person in a safe way; Semaphore sends no invitations.

Then, in the project, Team > New Team Member: choose the User and a Role:

  • Owner: everything, including deleting the project.
  • Manager: changes templates, keys, inventories and the team.
  • Task Runner: runs the templates, changes nothing.
  • Guest: reads templates, runs and their output, and nothing more.

In our test, a guest could read the output of every run, but got 403 Forbidden when trying to start a run or create a template, and saw the names of the keys but not the keys. Roles of your own are an Enterprise feature.

12. Email and other alerts

Semaphore sends one kind of email: an alert when a run fails, to each user who has Send alerts ticked, in projects with Allow alerts for this project. It has no password reset by email, so nothing else stops working without it.

To send email through your email provider, store the SMTP password as semaphore:

printf %s 'the-smtp-password' | podman secret create semaphore-mail-password -

Then add these lines to semaphore-app.container, with your own server, account and sender address:

Environment=SEMAPHORE_EMAIL_ALERT=true SEMAPHORE_EMAIL_HOST=smtp.example.com SEMAPHORE_EMAIL_PORT=587 SEMAPHORE_EMAIL_SECURE=true
Environment=SEMAPHORE_EMAIL_USERNAME=ops@example.com SEMAPHORE_EMAIL_SENDER=ops@example.com
Secret=semaphore-mail-password,type=env,target=SEMAPHORE_EMAIL_PASSWORD
systemctl --user daemon-reload
systemctl --user restart semaphore-app

SEMAPHORE_EMAIL_SECURE=true makes Semaphore log in with the username and password. On port 587, it switches to TLS with STARTTLS when the server offers it. For port 465, which uses TLS from the start, change the port and add SEMAPHORE_EMAIL_TLS=true.

Then tick Send alerts for your account under Edit Account. Test Alerts in the project's Settings sends a test alert to everyone who gets them. In our test, against a test mail server on the server itself, a failed run sent Task 'Broken' failed with a link to the run at https://ops.example.com. We did not send through a real email provider.

Semaphore can also post to Slack, Telegram, Microsoft Teams, Rocket.Chat, DingTalk and Gotify, for successful runs as well as failed ones, set with SEMAPHORE_SLACK_ALERT=true and SEMAPHORE_SLACK_URL, SEMAPHORE_TELEGRAM_ALERT, SEMAPHORE_TELEGRAM_CHAT and SEMAPHORE_TELEGRAM_TOKEN, and so on. Store tokens as secrets as above. Suppress success notifications on a template keeps those channels to failures. We did not test them.

13. Start a run from the API

Everything in the web interface goes through Semaphore's REST API, which scripts can use too. Open the menu with your name, API Tokens > New Token, give it a Token Name, and copy the token. It acts as you, with all your rights, so for a script make a user of its own with the Task Runner role and a token for that user.

The numbers in the address of a template's page, such as /project/1/templates/1, are the project and template IDs. To start a run, from any computer:

curl -s -X POST https://ops.example.com/api/project/1/tasks \
  -H 'Authorization: Bearer YOUR_TOKEN' -H 'Content-Type: application/json' \
  -d '{"template_id": 1, "environment": "{\"note\": \"Started from the API\"}"}'

The answer has the run's id and "status":"waiting". environment sets survey variables; leave it out to use the variable group. GET /api/project/1/tasks/ID shows the status, and /api/project/1/tasks/ID/output the output. Semaphore's documentation describes the rest of the API.

14. Outside connections

We captured the server's DNS lookups and new outgoing connections, over IPv4 and IPv6, while Semaphore started, while users logged in and ran playbooks, and while a schedule ran. Semaphore made none of its own: no update check and no usage data. The web interface loads its fonts and icons from Semaphore itself; links to Semaphore's site appear next to Pro features, and only open if someone clicks them.

What it connects to is what you give it: the Git repositories in Repositories, the servers in your inventories over SSH, your SMTP server and alert services. If a repository has a requirements.yml, Semaphore installs the roles and collections it lists from Ansible Galaxy, galaxy.ansible.com, before each run; Skip Galaxy install on the template stops that.

15. Back up

As semaphore:

umask 077
mkdir -p ~/backup
podman exec semaphore-db pg_dump -U semaphore -d semaphore > ~/backup/semaphore-db.sql
for s in access-key-encryption cookie-hash cookie-encryption; do
  podman secret inspect --showsecret --format '{{.SecretData}}' semaphore-$s > ~/backup/$s.txt
done
cp ~/.config/containers/systemd/semaphore* ~/backup/
tar -C ~ -cf ~/backup/repos.tar repos

The database has the projects, users, keys, inventories, templates, schedules, tokens and the output of every run. The keys are useless without access-key-encryption.txt, which the loop saves with the cookie keys. With the Quadlet files and the Git repositories, that is all of Semaphore: the files it keeps in /tmp/semaphore are clones it makes again. The dump runs while Semaphore keeps working. Copy ~/backup to another machine, for example with restic, and store it safely: with the key, it opens every SSH key in Semaphore.

To restore on a new server, or after removing everything, do step 1, then, as semaphore, with the backup in ~/backup:

openssl rand -hex 24 | tr -d '\n' | podman secret create semaphore-db-password -
for s in access-key-encryption cookie-hash cookie-encryption; do
  tr -d '\n' < ~/backup/$s.txt | podman secret create semaphore-$s -
done
tar -C ~ -xf ~/backup/repos.tar
mkdir -p ~/.config/containers/systemd
cp ~/backup/semaphore.pod ~/backup/semaphore-*.container ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user start semaphore-db
podman exec -i semaphore-db psql -q -U semaphore -d semaphore < ~/backup/semaphore-db.sql > /dev/null
systemctl --user start semaphore-pod
systemctl --user enable --now podman-auto-update.timer

Starting only the database first loads the backup before Semaphore sets up an empty one. > /dev/null hides the many lines psql prints for the dump; errors still show. If you set up email, create semaphore-mail-password again before the last two lines. In our test, the restored Semaphore had the same projects, templates, schedules, users and tokens, the old password and API token worked, and a playbook ran with the SSH key from the backup.

16. Update

The Semaphore image is pinned to a version. When a new one comes out, read the release notes on GitHub, make a backup, then change the tag in semaphore-app.container, such as v2.19.16 to v2.19.17, and restart:

systemctl --user daemon-reload
systemctl --user restart semaphore-app
podman image rm docker.io/semaphoreui/semaphore-community:v2.19.16

Semaphore updates its database when it starts. Our update from 2.19.15 to 2.19.16 took about a minute, most of it downloading the new image. The last line removes the old one, which frees about 1.3 GB. podman auto-update keeps PostgreSQL 18 up to date by itself. Semaphore 2.20 was in testing when we wrote this.

Troubleshooting

semaphore-app keeps restarting, and its log says pq: SSL is not enabled on the server. The SEMAPHORE_DB_OPTIONS line is missing. Check it with journalctl --user -u semaphore-app.

A run fails with detected dubious ownership in repository. The UserNS=keep-id line in semaphore.pod is missing, or the repository in ~/repos belongs to another user than semaphore.

A run fails with UNREACHABLE and Connection refused for this server. The inventory uses the server's public name or address, which leads back into the pod. Use host.containers.internal for this server.

UNREACHABLE with Permission denied (publickey). The public key is not in ~/.ssh/authorized_keys of the user in the inventory, or the key in the Key Store has another Username.

The page stays blank, or login works but nothing loads. SEMAPHORE_WEB_ROOT does not match the address in your browser. It must be the full https:// address that Caddy serves.

No email arrives after a failed run. Check that the project has Allow alerts for this project and your account Send alerts. The run's output ends with Sent successfully email alert to or the error from the mail server.

Run it on your own server

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy

€8.99/mo

vCPU
2
Memory
8 GB
Storage
60 GB
Transfer
10 TB
Performance
SSD · RAID 1
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides