Semaphore UI on Ubuntu 24.04 on Azure User Guide
Overview
Semaphore UI is the open source web interface and REST API for running automation. It turns Ansible playbooks, OpenTofu and Terraform plans, Terragrunt, PowerShell and shell scripts into named templates your team can run from a browser, with scheduled and event-triggered runs, live streaming output, a full task history showing who ran what and when, role based access control across projects, and an API and webhook surface for wiring runs into an existing pipeline.
The cloudimg image delivers Semaphore UI 2.19.12 on Ubuntu 24.04, served over HTTPS by Semaphore itself, with ansible-core 2.21.4 and OpenTofu 1.12.6 installed alongside it, an empty datastore, and every secret generated on the first boot of your VM. Backed by 24/7 cloudimg support.
What is included:
- Semaphore UI 2.19.12 at
/usr/bin/semaphore, run by systemd as the unprivilegedsemaphoreuser, built from the project's community release artifact so the image contains only MIT-licensed code - The web interface and REST API served over HTTPS on port 443 with a certificate generated for your VM; port 80 only redirects browsers to HTTPS and refuses anything that could carry a password
- An embedded SQLite datastore at
/var/lib/semaphore/semaphore.db— no separate database server to run, patch or back up - ansible-core 2.21.4 in an isolated virtual environment at
/opt/semaphore-ansible, on Ubuntu's own Python 3.12, and OpenTofu 1.12.6 at/usr/local/bin/tofu— both registered with Semaphore automatically - No default login: the administrator account, the session cookie keys, the TLS certificate and the key that encrypts your stored secrets are all created on your VM's first boot, before the web interface is allowed to start
- Self-service registration closed, including the runner registration endpoint, and project creation restricted to administrators
- A
semaphoresystemd service, enabled and active
Semaphore UI, Ansible, OpenTofu and Terraform are trademarks of their respective owners. All product and company names are trademarks or registered trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them. This image packages the unmodified open source software, which is distributed under the MIT License.

Prerequisites
An active Azure subscription, an SSH key pair, and a VNet plus subnet in the target region. Standard_B2s (2 vCPU, 4 GiB RAM) runs Semaphore and its engines comfortably for a small team; choose a larger size if you run many tasks in parallel or against large inventories. Network security group inbound rules: 22/tcp from your management network and 443/tcp for the web interface, from your own addresses only — see Step 11, which matters more for this product than for most.
Step 1: Deploy from the Azure Marketplace
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for Semaphore UI by cloudimg and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTPS (443). Then select Review + create and Create.
Once the VM exists, narrow those two rules to your own source addresses (Step 11).
Step 2: Deploy from the Azure CLI
az vm create \
--resource-group my-resource-group \
--name my-semaphore \
--image cloudimg:semaphore-ui-ubuntu-24-04:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values ~/.ssh/id_rsa.pub \
--public-ip-sku Standard \
--os-disk-delete-option Delete \
--nic-delete-option Delete
Then open the web port to your own address range:
az vm open-port --resource-group my-resource-group --name my-semaphore --port 443 --priority 1001
Step 3: Connect to your VM
ssh azureuser@<vm-ip>
The login user is azureuser (or whichever administrator name you chose at deployment). The image carries no build-time account and no password login.
Step 4: Confirm Semaphore is running
First boot takes under a minute: it generates this VM's TLS certificate, both session cookie keys, the key that encrypts your stored secrets, and the administrator password, creates an empty datastore, and only then allows the web interface to start. Until it finishes, the interface is deliberately not reachable.
sudo systemctl is-active semaphore
active
Check the product and its engines:
semaphore version && ansible --version | head -1 && tofu version | head -1
2.19.12-012ed06-1788086239
ansible [core 2.21.4]
OpenTofu v1.12.6
Semaphore discovers its engines on PATH when it starts, so these are exactly the binaries your tasks will run.

Step 5: Check what the network can reach
sudo /usr/local/sbin/semaphore-port-check.sh
off-box TCP exactly [22 80 443]
SEMAPHORE_PORTS_OK
That is the complete off-box surface of this image: SSH, the HTTPS front door, and the HTTP redirect. Semaphore terminates TLS itself, so there is no reverse proxy in front of it and no second certificate to rotate. The datastore is a file on disk, so nothing database-shaped listens at all.
sudo ss -Hltn | awk '{print $4}' | sort -u
*:443
*:80
0.0.0.0:22
127.0.0.53%lo:53
127.0.0.54:53
[::]:22
Step 6: Retrieve the first-boot credentials
sudo stat -c '%A %U:%G %n' /root/semaphore-credentials.txt /etc/semaphore/config.json
-rw------- root:root /root/semaphore-credentials.txt
-rw-r----- root:semaphore /etc/semaphore/config.json
sudo cat /root/semaphore-credentials.txt
The file records this VM's web address, the administrator login and the password generated for it. Nothing in it exists in the image — every value is minted on this VM's first boot, before the web interface serves a single request.
The configuration file alongside it holds three more per-VM secrets. The most important is access_key_encryption, which encrypts every SSH key and secret you later store in Semaphore:
sudo python3 -c "import json; d=json.load(open('/etc/semaphore/config.json')); print('\n'.join('%-26s %s' % (k, ('set, %d chars' % len(d[k])) if k in d else 'absent') for k in ('access_key_encryption','cookie_hash','cookie_encryption','runner_registration_token')))"
access_key_encryption set, 44 chars
cookie_hash set, 44 chars
cookie_encryption set, 44 chars
runner_registration_token absent
The last line is deliberate. POST /api/internal/runners is unauthenticated by design, and a configured global registration token would let anyone who can reach this server register a runner that executes tasks. Leaving it unset closes that door; Step 7 proves it is closed.

Step 7: Confirm the security posture
Before you sign in, confirm from the command line that nothing else can:
sudo /usr/local/sbin/semaphore-verify-login.sh /root/semaphore-credentials.txt
front door: this VM's credential accepted (204), cookie Secure+HttpOnly+SameSite, session usable
refused: admin/admin and 6 other weak defaults, blank password, anonymous API, anonymous user creation
self-registration: runner registration refused for every token; no global token configured
transport: plaintext not served on 443; :80 redirects GET (307) and refuses POST (403)
SEMAPHORE_LOGIN_OK
Each of those is a live request against your own running server, not a claim about the image. You can reproduce any one of them by hand — for example the credential every Semaphore tutorial uses:
curl -sk -o /dev/null -w '%{http_code}\n' -X POST \
-H 'Content-Type: application/json' \
--data '{"auth":"admin","password":"admin"}' \
https://127.0.0.1/api/auth/login
401

Step 8: Sign in
Open https://<vm-ip>/ in a browser. The certificate is generated for your VM and is self-signed, so the browser warns on the first visit; Step 12 shows how to install a certificate for a real domain name.
Sign in as admin with the password from Step 6. A fresh instance has no projects yet — create one from the project selector at the top left, then add a repository, an inventory and a task template.

Step 9: Run your first task
A task template ties together four things: a repository holding your playbooks, an inventory describing the hosts, a variable group for environment values, and the playbook to run. Create them from the left-hand navigation in that order, then choose Task Templates → New Template.

Press the run button on the template. The task opens a live log: Semaphore clones the repository, installs any Galaxy requirements it finds, then streams the engine's real output as it runs.

The same thing is available from the API. This signs in with your own credentials and lists the apps Semaphore has registered:
PASS=$(sudo grep '^SEMAPHORE_ADMIN_PASSWORD=' /root/semaphore-credentials.txt | cut -d= -f2-)
JAR=$(mktemp)
printf '{"auth":"admin","password":"%s"}' "$PASS" | curl -sk -o /dev/null -c "$JAR" \
-X POST -H 'Content-Type: application/json' \
https://127.0.0.1/api/auth/login --data-binary @-
curl -sk -b "$JAR" https://127.0.0.1/api/apps \
| python3 -c "import json,sys; d=json.load(sys.stdin); rows=d if isinstance(d,list) else list(d.values()); print('\n'.join(sorted(r.get('id','') for r in rows if isinstance(r,dict))))"
rm -f "$JAR"
ansible
bash
tofu
Step 10: The automation engines
Semaphore runs the same binaries you can run yourself, so anything you can debug from the shell behaves identically inside a task.
cd /tmp && ansible localhost -m ansible.builtin.ping
localhost | SUCCESS => {
"changed": false,
"ping": "pong"
}

ansible-core is installed from a version-pinned wheel into /opt/semaphore-ansible, isolated from the system Python so it cannot collide with anything the operating system manages. To move to a newer release:
sudo /opt/semaphore-ansible/bin/pip install --upgrade 'ansible-core==2.21.*'
sudo systemctl restart semaphore
Collections and roles install into the same environment:
sudo /opt/semaphore-ansible/bin/ansible-galaxy collection install community.general
Step 11: Restrict network access
This step matters more for Semaphore than for most products. Semaphore exists to execute automation, and it holds the SSH keys and secrets that reach your estate, so anyone who can both reach it and authenticate to it can run code on the hosts it manages. Treat the instance as infrastructure, not as a public web application.
Limit both inbound rules to the addresses your team actually uses:
az network nsg rule update \
--resource-group my-resource-group \
--nsg-name my-semaphoreNSG \
--name allow-https \
--source-address-prefixes 203.0.113.0/24
If your team does not have stable source addresses, put the instance on a private subnet and reach it over a VPN, Azure Bastion or a peered network instead of a public IP. The image does not open any port by itself; everything reachable is something the network security group allows.
Step 12: Use your own domain and certificate
Semaphore reads its certificate from /etc/ssl/semaphore/. Point a DNS record at your VM, obtain a certificate however you normally do, then install it and tell Semaphore its public URL:
sudo install -o root -g semaphore -m 0644 /path/to/fullchain.pem /etc/ssl/semaphore/semaphore.crt
sudo install -o root -g semaphore -m 0640 /path/to/privkey.pem /etc/ssl/semaphore/semaphore.key
sudo python3 - <<'PY'
import json
p = "/etc/semaphore/config.json"
c = json.load(open(p))
c["web_host"] = "https://semaphore.example.com"
json.dump(c, open(p, "w"), indent=2)
PY
sudo systemctl restart semaphore
web_host is load-bearing: Semaphore only marks its session cookie Secure when that value is an https URL, and the web interface uses it as the base for every asset it serves. Keep the scheme as https.
Step 13: Change the administrator password
Change the password from the web interface — the avatar at the bottom left, then Edit account. From the command line, semaphore can do it too. Choose a strong value; this block refuses an empty or trivially weak one rather than accepting it:
NEW='ReplaceThisWithYourOwnStrongValue'
if [ ${#NEW} -lt 16 ] || [ "$NEW" = "admin" ] || [ "$NEW" = "password" ]; then
echo "REJECTED: choose a password of at least 16 characters"
else
echo "would run: sudo semaphore user change-by-login --login admin --password ... --config /etc/semaphore/config.json"
fi
would run: sudo semaphore user change-by-login --login admin --password ... --config /etc/semaphore/config.json
After changing it, update /root/semaphore-credentials.txt so the file and the live password do not disagree.
Step 14: Back up your instance
Everything that matters lives in two places: the datastore, and the configuration file holding the key that decrypts it.
sudo install -d -m 0700 /root/semaphore-backup
sudo sqlite3 /var/lib/semaphore/semaphore.db ".backup '/root/semaphore-backup/semaphore.db'"
sudo cp -a /etc/semaphore/config.json /root/semaphore-backup/config.json
sudo ls -l /root/semaphore-backup
.backup takes a consistent copy of a live database, so you do not have to stop the service. Keep both files together. The SSH keys and secrets in the datastore are encrypted with access_key_encryption from the configuration file; a datastore restored without it cannot be decrypted, and the configuration file without the datastore is useless. Store them somewhere at least as protected as the credentials they unlock.
Step 15: Keep the image patched
Ubuntu security updates are applied automatically by unattended-upgrades, which is enabled in this image:
systemctl is-enabled unattended-upgrades
enabled
Semaphore itself is a single binary. To move to a newer release, download the community artifact for the version you want, verify it against the checksums file published with that release, then install it:
echo "sudo apt-get install -y ./semaphore_community_<version>_linux_amd64.deb && sudo systemctl restart semaphore"
sudo apt-get install -y ./semaphore_community_<version>_linux_amd64.deb && sudo systemctl restart semaphore
Take the artifact whose name contains community: every Semaphore release also publishes a larger build that links the project's separately licensed Pro module, and this image ships the community build deliberately so it contains only MIT-licensed code.
Troubleshooting
The web interface does not answer. First boot has not finished, or it failed. Check both units:
sudo systemctl is-active semaphore-firstboot semaphore
active
active
If semaphore-firstboot is not active, read its log:
sudo journalctl -u semaphore-firstboot --no-pager | tail -20
The server will not start until first boot has written /var/lib/cloudimg/semaphore.bootstrap-ready, which is deliberate: it stops the instance from ever serving without an administrator and an encryption key.
A task fails immediately with a repository problem. Semaphore clones with git as the semaphore user. For a private repository, add an SSH key in Key Store and select it on the repository; for a host it has not seen before, its key must be accepted for that user:
sudo -u semaphore ssh-keyscan -H github.com 2>/dev/null | sudo -u semaphore tee -a /var/lib/semaphore/.ssh/known_hosts >/dev/null
echo done
done
A task fails to reach your hosts. Semaphore runs Ansible as the semaphore user, so the key it uses is the one you attached to the inventory, not any key in your own home directory.
Check everything at once. The image ships a self-test that re-runs every security assertion against the running instance — the listening set, the login gates, the transport posture, the engine versions, and the fact that the certificate really names your VM:
sudo /usr/local/sbin/semaphore-selftest.sh
SEMAPHORE_SELFTEST_OK
Adding --customer-vm also asserts the image-level facts — that no build account survives, that first boot completed, and that the image carries no swap — which is what cloudimg's own release gate runs against a freshly launched instance.
Support
This image is supported 24/7 by cloudimg. Contact us through the cloudimg website for help with deployment, TLS certificates and custom domains, connecting inventories and source repositories, runner and pipeline design, upgrades and scaling.