Semaphore UI 2.19.17 on AWS User Guide
Overview
This guide covers launching and using the Semaphore UI 2.19.17 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg help desk, IT service and project management listing on AWS Marketplace; you choose it as the software version when you launch.
Semaphore UI is an open source web interface for infrastructure automation. You keep your Ansible playbooks and OpenTofu configurations in Git; Semaphore stores the inventories, SSH keys and variables they need, runs them as tasks from task templates, on demand or on a schedule, and keeps every run's full log, who started it and when. Teams use it to give operators a button for routine changes without handing out shell access.
What the cloudimg image adds:
- No default login, nothing baked in. The image ships no configuration, no datastore, no certificate and no keys. First boot creates this instance's datastore, the one administrator
adminwith a password generated for your instance, and the three keys Semaphore uses to sign sessions and encrypt everything you put in its Key Store. No demo projects, users or API tokens ship. - HTTPS from the first boot. nginx serves Semaphore on port 443 with a certificate generated on your instance, port 80 redirects to HTTPS, and a request for a host name the instance does not serve gets no answer.
- Semaphore on the loopback interface only. The Semaphore service listens on
127.0.0.1:3000; only nginx can reach it. - Cross site requests refused. nginx refuses sign in requests and WebSocket connections from any origin other than your Semaphore address, on top of Semaphore's own cross site request check.
- Sign in attempts rate limited per client address, and the address Semaphore records for each session is the real client address, never a forwarded header.
- Tasks run as an unprivileged user. Every playbook and plan runs as the system user
semaphore: no shell, no password, no sudo, no capabilities,NoNewPrivileges, and a read only view of the rest of the system. It can write only Semaphore's own data directory. - Metadata access blocked, including from your tasks. Semaphore, every task it runs, nginx and first boot are blocked from the instance metadata service, so a playbook cannot read the instance's IAM role credentials. The AMI requires IMDSv2.
- No remote runner registration. Semaphore's runner registration endpoint is unauthenticated when a registration token is set, so this image sets none and runs every task locally. Only administrators create projects.
- The operating system Git client. Repositories are cloned with the
gitpackage from Ubuntu rather than Semaphore's built in Go Git library. - The MIT community build only. Semaphore publishes two builds of each release; the image installs the community build by checksum and refuses the other one.
- Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.
What is on the image: Semaphore UI 2.19.17 (the upstream community build semaphore_community_2.19.17_linux_amd64.deb, checksum verified) with its SQLite datastore, ansible-core 2.21.6 in its own Python environment /opt/semaphore-ansible, OpenTofu 1.13.9, Git, the OpenSSH client, nginx, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.
Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large
Prerequisites
- An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet (or a private route you use for management).
- A security group that allows TCP 22 (SSH) from your admin network, and TCP 443 (Semaphore) and TCP 80 (redirects to HTTPS) from the networks your users are on. Keep 80 and 443 as narrow as you can.
- For the hosts your playbooks manage: network access from this instance to them over SSH, and an SSH key they accept.
Step 1: Launch the instance
In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick Semaphore UI 2.19.17 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large (or larger if you run many tasks at once), your key pair and a security group as above.
With the AWS CLI (replace the values in angle brackets):
aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
--instance-type m5.large --key-name <your-key-pair> \
--security-group-ids <your-security-group> --subnet-id <your-subnet> \
--metadata-options HttpTokens=required,HttpEndpoint=enabled \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=semaphore}]'
First boot takes under a minute after the instance is running.
Step 2: Connect to your instance
| Version | SSH user |
|---|---|
| Semaphore UI 2.19.17 on Ubuntu 24.04 | ubuntu |
ssh -i <your-key.pem> ubuntu@<public-ip>
Step 3: Check the services
First boot writes the configuration, creates the datastore and the administrator and mints the certificate; nginx and Semaphore start only after it has finished:
systemctl is-active nginx semaphore
sudo test -f /var/lib/cloudimg/semaphore-firstboot.done && echo FIRSTBOOT_DONE
Both are active and the first boot marker is present:
active
active
FIRSTBOOT_DONE
Only SSH and nginx listen on network ports. Semaphore listens on the loopback interface only:
sudo ss -lntp | awk 'NR>1 && $4 !~ /^127\.0\.0\.5[34]/ {split($6, p, "\""); print $4, p[2]}' | sort
0.0.0.0:22 sshd
0.0.0.0:443 nginx
0.0.0.0:80 nginx
127.0.0.1:3000 semaphore
[::]:22 sshd
[::]:443 nginx
[::]:80 nginx
The versions of Semaphore and the tools it runs:
semaphore version
ansible --version | head -1
tofu version | head -1
git --version
2.19.17-f8cefe6-1792391823
ansible [core 2.21.6]
OpenTofu v1.13.9
git version 2.43.0
Step 4: Get your URL and password
sudo cat /root/semaphore-credentials.txt
The file is readable by root only:
# Semaphore UI 2.19.17 - generated on first boot by semaphore-firstboot.service
# These credentials are unique to this instance; none was baked into the image. Keep this file secret.
SEMAPHORE_URL=https://203.0.113.10/
SEMAPHORE_HOST=203.0.113.10
SEMAPHORE_ADMIN_USER=admin
SEMAPHORE_ADMIN_PASSWORD=<SEMAPHORE_ADMIN_PASSWORD>
#
# Open SEMAPHORE_URL in a browser (self-signed certificate minted on this instance's first boot; port 80 redirects to
# https) and sign in with the user and password above. Then change the password (your avatar > Edit user).
# The access-key encryption key in /etc/semaphore/config.json is also unique to this instance: it encrypts every SSH
# key and secret you store in Semaphore. Back it up with the datastore /var/lib/semaphore/db/semaphore.db.
# Tasks run as the OS user semaphore (no sudo, no instance-metadata access).
# Logs: journalctl -u semaphore Change the name it serves as: sudo semaphore-set-hostname <your-dns-name>
# User guide: https://www.cloudimg.co.uk/guides/semaphore-ui-2-19-17-on-ubuntu-24-04-aws/
SEMAPHORE_URL uses the instance's public IPv4 address until you set your own name (Step 8). If the instance has no public address it uses the private one.
Step 5: Sign in and change the password
Open SEMAPHORE_URL in your browser. The certificate is self signed, so your browser asks you to confirm it the first time (or install your own, Step 8). Sign in as admin with the password from Step 4.

Change the password straight away: open the menu under your name at the bottom left, choose Edit Account and set a new password. Under Users (in the same menu) add an account for each person, and keep the Admin box for the people who manage Semaphore itself. A new instance has exactly one user:

Step 6: Create a project and run a playbook
A new instance has no projects. Choose New project, give it a name, and then fill in, in this order:
- Key Store: the SSH private key your managed hosts accept (type SSH Key), and a None key for public Git repositories. Semaphore encrypts every key with this instance's own access key encryption key.
- Repositories: the Git URL of your playbooks and the branch, with the key Semaphore should use to clone it.
- Inventory: your hosts, as a static inventory or a file in the repository, with the SSH key to log in to them.
- Variable Groups: extra variables and environment variables for the runs.
- Task Templates: one template per playbook, tying the playbook to an inventory, a repository and a variable group.
Then choose Run on a template. Each run appears under Dashboard, History with its status, who started it and how long it took:


Open a run to see its full Ansible log:

Tasks run on this instance as the user semaphore, which cannot use sudo, so point your inventories at the hosts you manage over SSH rather than at localhost. To use become on those hosts, add the sudo password as a key of type Login with password and pick it as the inventory's Become key.
Step 7: OpenTofu templates
OpenTofu templates take a workspace inventory instead of a host list: under Inventory choose New inventory, type OpenTofu Workspace, and enter the workspace name (default for the default workspace). Create the template with the app OpenTofu Code, your repository and that inventory. A run plans first and waits for you to confirm the plan unless the template has Auto approve ticked:

Keep OpenTofu state in a remote backend (for example an S3 bucket) declared in your configuration. A plan run by Semaphore cannot read this instance's IAM role, because tasks are blocked from the instance metadata service, so give OpenTofu its cloud credentials through a variable group or the Key Store.
Step 8: Your own DNS name and certificate
Point a DNS record at the instance (an Elastic IP keeps the address stable), then set the name Semaphore is served as. This mints a new self signed certificate for the name, points nginx at it, sets Semaphore's web address and the allowed origin, updates /root/semaphore-credentials.txt, and keeps the name across reboots:
sudo semaphore-set-hostname <your-domain>
To use a certificate from a public or internal certificate authority instead, copy it over the generated pair and reload nginx (running semaphore-set-hostname again replaces it with a self signed one):
sudo install -m 0644 <your-domain>.crt /etc/nginx/ssl/semaphore.crt
sudo install -m 0600 <your-domain>.key /etc/nginx/ssl/semaphore.key
sudo nginx -t && sudo systemctl reload nginx
Until you set a name, the image follows the instance's address: if you stop and start the instance and it gets a new public address, the next boot moves Semaphore and its certificate to that address.
Step 9: Security checks
nginx serves only Semaphore's pages and API. The configuration, the datastore and dotfiles answer 404, the API needs a signed in session, plain HTTP redirects to HTTPS, a request for a name the instance does not serve gets no answer, and a sign in from another origin is refused:
H=$(sudo grep '^SEMAPHORE_HOST=' /root/semaphore-credentials.txt | cut -d= -f2-)
for p in /auth/login /api/ping /healthz /api/projects /api/users /api/runners /etc/semaphore/config.json /db/semaphore.db /.git/config; do
curl -sk -o /dev/null -w "%{http_code} $p\n" --resolve "$H:443:127.0.0.1" "https://$H$p"
done
curl -s -o /dev/null -w 'http:// -> %{http_code}\n' --resolve "$H:80:127.0.0.1" "http://$H/"
curl -sk -o /dev/null -w 'unknown host name -> %{http_code}\n' --resolve "other.example:443:127.0.0.1" "https://other.example/" || true
curl -sk -o /dev/null -w 'sign in from another origin -> %{http_code}\n' --resolve "$H:443:127.0.0.1" \
-H 'Origin: https://other.example' -H 'Content-Type: application/json' -d '{"auth":"admin","password":"x"}' "https://$H/api/auth/login"
200 /auth/login
200 /api/ping
200 /healthz
401 /api/projects
401 /api/users
401 /api/runners
404 /etc/semaphore/config.json
404 /db/semaphore.db
404 /.git/config
http:// -> 301
unknown host name -> 000
sign in from another origin -> 403
The credential check signs in through the API with your password, and confirms that a wrong password and admin/admin are refused and that remote runner registration is shut:
sudo semaphore-cred-roundtrip
OK https 301 -> https://203.0.113.10/; certificate verifies for 203.0.113.10; admin 204 (cookie Secure HttpOnly SameSite=Lax, /api/user admin); wrong and admin/admin 401, empty refused; foreign Origin 403; anonymous API 401; runner registration shut (400)
Every service runs with the instance metadata service blocked, and Semaphore (with every task it starts) runs as semaphore with no privileges:
for u in nginx semaphore semaphore-firstboot semaphore-host-follow; do echo "$u $(systemctl show -p IPAddressDeny --value $u)"; done
systemctl show semaphore -p User -p NoNewPrivileges -p ProtectSystem -p CapabilityBoundingSet
sudo -l -U semaphore | tail -1
nginx fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
semaphore fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
semaphore-firstboot fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
semaphore-host-follow fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
CapabilityBoundingSet=
User=semaphore
ProtectSystem=strict
NoNewPrivileges=yes
User semaphore is not allowed to run sudo on ip-172-31-90-141.
A playbook that tries to reach the metadata service from inside a task fails with a timeout. This run of a test playbook shows the request refused:

The configuration is readable only by Semaphore, and the datastore only by the semaphore user:
sudo stat -c '%U:%G %a %n' /etc/semaphore/config.json /var/lib/semaphore /var/lib/semaphore/db/semaphore.db
root:semaphore 640 /etc/semaphore/config.json
root:semaphore 750 /var/lib/semaphore
semaphore:semaphore 640 /var/lib/semaphore/db/semaphore.db
Step 10: Backups and maintenance
Everything Semaphore stores is in its SQLite datastore /var/lib/semaphore/db/semaphore.db. The keys and secrets in it are encrypted with the access_key_encryption key in /etc/semaphore/config.json, so back the two up together: a datastore without its configuration cannot decrypt your Key Store. Both are on the root volume, so the simplest backup is an EBS snapshot of it. For a consistent copy you can keep elsewhere:
sudo sqlite3 /var/lib/semaphore/db/semaphore.db ".backup '<backup-dir>/semaphore-$(date +%F).db'"
sudo install -m 0600 /etc/semaphore/config.json <backup-dir>/semaphore-config-$(date +%F).json
Operating system security updates install automatically; run sudo apt-get update && sudo apt-get upgrade to apply everything else, and nginx and Semaphore restart cleanly with all of their data. ansible-core lives in its own Python environment, which apt does not update; to move to a newer ansible-core 2.21 release, or to add Python libraries your playbooks need:
sudo /opt/semaphore-ansible/bin/python -m pip install --upgrade 'ansible-core>=2.21,<2.22'
Install Ansible collections from a collections/requirements.yml file in your playbook repository; Semaphore installs them at the start of each run. New Semaphore releases arrive as new versions of this listing, and support can help you move your datastore to one.
| Component | Path |
|---|---|
| Semaphore | /usr/bin/semaphore (the community build, owned by root) |
| Configuration | /etc/semaphore/config.json (root:semaphore, 0640) |
| Datastore | /var/lib/semaphore/db/semaphore.db |
| Working files and clones | /var/lib/semaphore/tmp |
| ansible-core | /opt/semaphore-ansible (linked into /usr/local/bin) |
| OpenTofu | /usr/local/bin/tofu |
| Logs | journalctl -u semaphore, /var/log/nginx |
| nginx site | /etc/nginx/sites-available/cloudimg-semaphore |
| Certificate and key | /etc/nginx/ssl/semaphore.crt, /etc/nginx/ssl/semaphore.key |
| Helpers | /usr/local/sbin/semaphore-set-hostname, /usr/local/sbin/semaphore-cred-roundtrip |
| Credentials | /root/semaphore-credentials.txt (mode 0600) |
| Source and licences | /usr/share/doc/semaphore-cloudimg/ |
Step 11: Security recommendations
- Allow port 22 from your admin network only, and ports 80 and 443 only from the networks your users are on.
- Use your own DNS name and a certificate your browsers trust (Step 8).
- Change the
adminpassword as soon as you sign in, give each person their own account, and keep the Admin role for the few who manage Semaphore. - Give Semaphore SSH keys that are limited to what its playbooks need, and use a separate key per environment.
- Do not give this instance an IAM role your playbooks should not have; tasks cannot read it, but other software you add might.
- Keep the operating system patched, and snapshot the root volume regularly.
Support and licensing
Semaphore UI is open source under the MIT licence, by Denis Gukov and the Semaphore UI contributors; the image ships the community build exactly as published. ansible-core is licensed under the GNU General Public License version 3 or later and OpenTofu under the Mozilla Public License 2.0. There is no per user licence fee. /usr/share/doc/semaphore-cloudimg/SOURCE.txt names each component, where it came from and its checksum, beside the licence files.
- Email: support@cloudimg.co.uk
- Website: https://www.cloudimg.co.uk
- Support: 24/7, with a 24 hour response SLA
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.